diff --git a/AGENTS.md b/AGENTS.md index c06e133..e822cf2 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` @@ -80,3 +80,15 @@ Then start the first implementation milestone: - work items - motions - ledgers + +## Live Postgres tests + +Most tests run on the in-memory EF provider, which enforces no unique index, transaction, concurrency token or trigger. The tests that need a real server (`tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs`: commit atomicity, the `(MergeId, Seq)` unique index, the sweeper racing the saga, overlapping merges refused by the database, the one-current-Patron-snapshot index under racing restages, the intake journal `Seq`, the write gate's shared lock racing a merge's freeze and commit, the migration's roll-forward-only guard) run only when `BIFROST_TEST_PG` holds a connection string with no `Database=`, for a role that may create databases; otherwise they are skipped with that reason. Each test creates its own database, builds it through the real migrations and the class drops it, so point the variable at a disposable server: + +```bash +BIFROST_TEST_PG='Host=127.0.0.1;Port=54329;Username=postgres' dotnet test tests/Bifrost.Web.Tests --filter FullyQualifiedName~AccountMergePostgresTests +``` + +## Account merges are roll-forward only + +Once any `AccountMerges` row exists, do not deploy a build older than the `AddAccountMerges` migration, and do not revert that migration: an older build cannot read the `Merged` membership status and counts voided votes again. `Down` refuses while any merge row exists. Fix forward (`heimdall-body:ruling:merge-roll-forward-only`, `docs/account-merge.md` Rollback). Any deploy list that names a rollback must say so. diff --git a/README.md b/README.md index 33302e1..ba62504 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 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 @@ -36,7 +37,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 @@ -89,7 +90,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/docs/account-merge.md b/docs/account-merge.md new file mode 100644 index 0000000..67e224b --- /dev/null +++ b/docs/account-merge.md @@ -0,0 +1,136 @@ +# Account merge ledger + +What a merge of two Bifrost accounts does to Bifrost records, who owns it, and how an account stands under one. Design: `/opt/commons/handoff/heimdall-account-merge-design-20261113.md` sections 3, 5 and 7. The ledger owns what a merge does to Bifrost records; the saga (below) owns the merge's lifecycle around it and every Heimdall call. The member's pages are in Pages and flags below; with `AccountMerge:Enabled` false (the default) no page can start a merge. + +## Owner + +`Features/Membership/AccountMergeLedger.cs` is the one owner of merge effects. Outside it nothing writes `VoidedByMergeId`, `MembershipStatus.Merged`, `AccountMergeEntry`, or the states Committed, Final and Undone, and nothing but the ledger calls `IgnoreQueryFilters` on `Vote` or `ConceptAppraisal`. The ledger never reads an email, display name or login string; accounts are matched by id. + +## States + +`AccountMerge.State` (stored as a string) declares the whole lifecycle once: `Proposed`, `Confirmed`, `HeimdallMoved`, `Committed`, `Final`, `UndoRequested`, `HeimdallReversed`, `Undone`, `Failed`. Later cuts add columns, not states. + +An account's standing is derived from that state on every request; there is no `MergedInto` column and `Membership.Status = Merged` is never read to decide it. + +| State of the merge the account is folded in | `StandingAsync` | +| --- | --- | +| Confirmed, HeimdallMoved | `Frozen(mergeId)` | +| Committed, Final, UndoRequested, HeimdallReversed | `Folded(mergeId, survivorId)` | +| Proposed, Failed, Undone | none | + +An alias is one hop: a landed merge's survivor never folds into a third account (the `folded_is_survivor` block), so a folded account's survivor is always the account that carries the person. Every patron event is recorded through `PatronageService.RecordSupportEventAsync`, which writes through the gate below: an event for a folded account (Heimdall intake, Stripe webhook, the admin Ledger's manual record) records on the survivor and is journalled in the same save, so an undo moves it back; a provider retry returns the stored event and journals it once if it sits on the survivor. + +`CurrentBifrostActorAccessor` is the one gate for every cookie-authenticated route: a Frozen or Folded account resolves to an authenticated actor with no account (`CurrentBifrostActor.Merge` set), so no route can act as it. The OIDC authorize endpoint denies it with `MergedDescription`, and `ChatRefusalAsync` refuses its token and userinfo, so the logout reconciler ends its chat sessions. The accessor looks an account up by identity, then Heimdall id, then GitHub id; the first hit wins and several matching rows can no longer throw. The same standing is read at the other doors: the desk bearer door (`ActiveMemberIdAsync`) refuses a Frozen or Folded subject, the linked-accounts page and the link step of Heimdall completion refuse one (no login linked or unlinked, no passkey enrolled while the saga moves them), and every writer reads it again at the write, through the gate below, so an actor resolved before the merge froze the account writes nothing after it. + +## Plan, commit, undo, finalize + +`PlanAsync(survivorId, foldedId)` writes nothing and returns the blocks (codes only), the journal entries a commit would write, and counts of rows left untouched. Blocks: `same_account`, `account_missing`, `mind_account`, `native_account` (either side has no Heimdall id: v1 merges Heimdall-backed accounts only), `merge_in_flight`, `survivor_is_folded`, `already_folded`, `folded_is_survivor`, `sealed_ballot_pending`. The database backs the two overlap blocks the plan checks first (check-then-act): a partial unique index lets an account be folded by one live merge only (Confirmed through HeimdallReversed), and a trigger refuses a live merge whose survivor is another live merge's folded account, taking advisory locks on both accounts so racing transactions serialise. + +`CommitAsync(mergeId)` needs `HeimdallMoved` (Committed and Final return the receipt; any other state throws). It is total: blocks were checked before the move, so it never refuses on them. It applies everything in one `SaveChanges` together with `State = Committed` and `GraceEndsUtc = CommittedUtc + 7 days`; a failed save leaves the merge `HeimdallMoved` and applies nothing, and a retry applies once. + +What it reduces: + +- **Suspension wins.** A suspended folded account and a survivor that is not suspended: the survivor becomes Suspended with the reason `merged with a suspended account (merge )`. A survivor without a membership row (the chat tier holds none) is given a Suspended one, journalled as `Standing`/`Created`, because an absent membership chats and the next member-access claim would create an Active one; undo removes it unless an admin has taken it over since. +- **Membership.** The folded membership becomes `Merged` (weight 0 by the not-Active rule). Roles and admin flags of the folded membership are never copied. +- **Votes on open standard motions.** Both voted: the folded vote is voided. Only the folded account voted: the vote moves to the survivor if the survivor is Active after the suspension rule, else it is voided. A move keeps the vote's cast time and takes the survivor's weight, derived from the survivor's tiers after the patron events moved (the folded weight is journalled for undo); a later survivor cast updates the moved row (`CastVoteAsync` is per motion and account), never a second row. Closed motions are untouched. +- **Concept appraisals.** The survivor also appraised the subject: the folded one is voided. Otherwise it moves to `account:` as a Member voter if the survivor is Active, else it is voided (the prior class and key are journalled). +- **Patron events** of the folded account move to the survivor, and both tiers are derived again once from the events each account now holds (never tier A plus tier B). This happens only when events moved, so a tier that no event backs is not wiped by a merge with no patron history (only `PatronageService` writes Patron snapshots, so a merge with no folded events leaves both tiers as they were). + +Voiding stamps `VoidedByMergeId` and `VoidedUtc`; the model's query filter on `Vote` and `ConceptAppraisal` then removes the row from every tally, including future ones. Nothing is deleted. + +Left untouched (counted by the plan): consent receipts, passkeys, ledger entries, points, work and revenue rows. + +`UndoLocalAsync(mergeId)` needs `HeimdallReversed` (Undone is a no-op). In one save it replays the journal backward: it un-voids votes whose motion is still Open and appraisals, moves a moved vote or appraisal back only if the survivor did not re-cast it since, restores the folded status, restores the survivor's status only while it is still Suspended with this merge's reason, moves patron events back and derives both tiers again. What it keeps it records as `KeptAfterUndo`; nothing is updated or deleted in the journal, only appended to. + +`FinalizeDueAsync(now)` moves Committed merges whose grace has ended to `Final`, one save per merge; `AccountMergeSweeper` runs it every 30 seconds, after the saga's due steps (see Saga). `State` is the merge's concurrency token, so a transition computed from a stale read (the sweeper's Final over the saga's UndoRequested, or the reverse) fails its save and the other transition stands; the sweep skips a merge it lost. `UndoLocalAsync` also refuses a merge with `FinalizedUtc` set: undo is never allowed past Final. + +## Saga + +`Features/Membership/AccountMergeSaga.cs` is the one owner of the merge row's lifecycle outside the ledger's Committed, Final and Undone, and the only caller of `HeimdallIdentityMoveClient.MoveAsync` (whose closed `Moved`/`Refused`/`Unknown` answer it consumes as is; the client retries nothing). It writes Confirmed, HeimdallMoved, Failed, UndoRequested and HeimdallReversed, the return UndoRequested to Committed, the move ids and the sealed proofs; it never writes a vote, membership or journal row (it calls `CommitAsync` and `UndoLocalAsync`), reads no standing of its own, and never judges a proof's tier or freshness (Heimdall's). + +| State | Written by | Key and proofs | A crash here | +| --- | --- | --- | --- | +| Confirmed | `ConfirmAsync`: one insert, the consent record | `HeimdallMoveId` minted (random, never derived from the merge id); both sides' Heimdall bearers sealed | Before the insert nothing exists. After it the sweep calls Heimdall with the stored id; a lost answer replays to the same receipt, because Heimdall answers a replay before it judges liveness. | +| HeimdallMoved | `AdvanceAsync` on `Moved`, one save | proofs wiped; receipt tiers and moved login ids stored | `CommitAsync` is total and idempotent; the sweep retries it. | +| Failed | `AdvanceAsync` on any `Refused` | proofs wiped; `FailureCode` is Heimdall's code | Terminal. Heimdall applied nothing, Bifrost changed nothing, the folded account stands as before. | +| Committed | ledger `CommitAsync`, one save with every reduction | grace 7 days | A crash before the save leaves HeimdallMoved. | +| UndoRequested | `RequestUndoAsync`: the survivor, fresh survivor proofs, inside the grace | `HeimdallReverseMoveId` minted; survivor bearers sealed | The sweep replays the reversal (past the grace too). A refused reversal returns the merge to Committed, proofs wiped, `LastOutcome` `undo_`. | +| HeimdallReversed | `AdvanceAsync` on the reversal's `Moved` | proofs wiped | `UndoLocalAsync` is retried. | +| Undone, Final | ledger | | | + +`AdvanceAsync(mergeId)` is the one step function: the confirm's inline advance, the sweeper's resume and an undo request all run it, forward move and reversal alike (same seal, same outcome handling, same backoff). Every transition is one save guarded by `State`, the concurrency token; the loser of a race clears its tracker, reloads and continues from the stored state, so two advances make one Heimdall move and one commit. `Proposed` is declared by the ledger and written by nobody in this cut. + +`ConfirmAsync` refuses (writing nothing) a third-party actor (`NotParty`), a plan with blocks (`Blocked`, codes only), a plan other than the one the member saw (`PreviewStale`: `MergePlan.Digest(survivorId, foldedId)`, a SHA-256 over a length-prefixed canonical text of the plan), and any proof of another Heimdall account (`ProofAccountMismatch`). A second live merge folding the same account, or a chain, is refused by the database (the live-merge unique index and the chain trigger) and answered `Blocked(merge_in_flight)`. Confirm and the undo request run through `AccountMergeLedger.WriteAsAsync` for the acting member, like every writer that acts for an account. + +**Heimdall down.** `Unknown` (a timeout, a 5xx, a 429, an unrecognised 4xx, `move_id_conflict`) never fails a merge: the saga counts `Attempts`, sets `NextAttemptUtc` (15 s, 1 min, 5 min, then 15 min) and replays the same stored id; the folded account stays Frozen until Heimdall answers. Heimdall's answer to a replay of a move it already stored does not depend on the proofs' age, but a move that never reached Heimdall does: an outage longer than the proofs' freshness (300 s for a login's weak proof, 600 s for a passkey's strong one) ends, when Heimdall is back, in `Refused proof_stale` and a Failed merge; the member confirms again with fresh sign-ins. A replay whose proofs no longer verify (Heimdall's key regenerated) answers `move_id_conflict` and stays Unknown; a receipt that names an account other than the destination is `receipt_mismatch` (proofs wiped, no retry); a sealed payload the key ring can no longer open is `proofs_unreadable` (retried in an hour, state unchanged, because Heimdall may already have moved). None of the three ends by itself: see Stuck merges. + +**Sealed proofs.** The saga holds the two Heimdall bearers (`ruling merge-sealed-proofs-until-move-final`) only between Confirmed (or UndoRequested) and Heimdall's final answer: protected with Data Protection (purpose `Bifrost.AccountMerge.Proofs.v1`), never logged, wiped in the save that records Moved or Refused. Logs name the merge id, state, outcome class and refusal code; never a token, account id, identity id or provider user id. Proof coverage is Heimdall's rule (`move-proof-strict-passkey`): a proof through one login moves only that login, and only an asserted passkey speaks for the whole account, so a folded account's every login must be proved (or a passkey asserted) or Heimdall answers `identity_not_proved`. A `moveId` replayed with other identities returns the first receipt (`move-replay-first-receipt`). + +`AccountMergeSweeper` is the one merge loop (30 s): per tick, in one scope, `MergeProofs.PruneExpired` (a proof bearer lives only in process memory and is dropped 270 s after its proof, on access and by this tick, so none outlives that plus one tick whether or not anyone opens the page; `heimdall-body:ruling:merge-proof-bearers-memory-bound`), then `AccountMergeSaga.AdvanceDueAsync` (every in-flight merge whose `NextAttemptUtc` is null or due, oldest first, a failure logged and skipped), then `FinalizeDueAsync`. + +## Stuck merges + +`/App/Merges` (platform admins only; the `/App` folder's ActiveMember policy, then `IsPlatformAdmin`) lists in-flight merges (Confirmed or UndoRequested) that stopped on `receipt_mismatch`, `proofs_unreadable` or `move_id_conflict`, or were retried eight times, with the merge id, state, last outcome, attempts and the stored move id of the leg in flight. It shows no Heimdall account id, login or account name, and calls no Heimdall. Only the first three outcomes can be ended (`AccountMergeSaga.SettleableOutcomes`); a merge listed for its attempts alone is still being retried. + +Two actions (`heimdall-body:ruling:merge-stuck-end-only-typed-id`), both `AccountMergeSaga.SettleStuckAsync`, both needing the leg's stored move id typed back (the forward id for a Confirmed merge, the reverse id for an UndoRequested one) and both writing one `merge.settled` audit row that names the merge and the choice only: + +| Action | Applies to | Result | +| --- | --- | --- | +| Fail the merge (`FailForward`) | Confirmed, stuck | `Failed`, `FailureCode` and `LastOutcome` `operator_failed`, proofs wiped. The folded account is free again; the pair can be confirmed anew. | +| Give up the undo (`AbandonUndo`) | UndoRequested, stuck | back to `Committed`, `LastOutcome` `undo_operator_abandoned`, proofs wiped, the grace as it was. The merge stands; the logins stay with the survivor. | + +HeimdallMoved and Committed are never settleable: Heimdall answered Moved with a matching receipt there, and the saga rolls forward. A settle and the sweeper's `AdvanceAsync` meet at the `State` concurrency token; the loser changes nothing and the other transition stands (answered `lost`). A settle does not fence a replay already in flight to Heimdall, so read `identity_moves` first and read it again after. + +Before ending a merge, read Heimdall's `identity_moves` row by the stored move id. If Heimdall did move the logins, failing the merge leaves them moved with no Bifrost merge to match: repair or reverse at Heimdall first. For `receipt_mismatch`, repair Heimdall's or Bifrost's account link before failing. Adopting a move Heimdall made (writing HeimdallMoved from a receipt) stays manual SQL until follow-up `merge-operator-adopt-moved`; the page never does it. + +## Pages and flags + +`Pages/Membership/MergeAccounts` is the one page that creates a merge. It is `[Authorize]`, no-store, a 404 unless `AccountMergeOptions.Enabled` (section `AccountMerge`, default false; environment `AccountMerge__Enabled`) and forbidden to a frozen or folded account, as Linked accounts is. Linked accounts shows one "Merge two accounts" link, only when the flag is on and the session is Heimdall-backed. + +Its stage is derived on every request from `MergeProofs.Snapshot` and the `keep` query; no cookie or form field carries progress except the chosen direction and the preview digest, which the saga re-checks. + +1. Prove: the member names a provider for this account (`MergeProveSelf`) and for the other (`MergeProveOther`). Nothing about the other account is rendered until both proofs are held. +2. Choose: both accounts are named by display name only, and `keep=self` or `keep=other` shows the preview. No survivor is preselected (the chat-history default needs `ChatFirstSignInUtc`, follow-up `merge-chat-default-survivor`; the chat paragraph carries the warning meanwhile). +3. Prove the rest: one fresh sign-in for each login of the account that would be folded and is not yet proved (`ProveRemaining`). Proofs older than `MergeProofs.ProofLifetime` fall out and are asked again. +4. Confirm: two checkboxes, one per account, the direction and the digest. `AccountMergeSaga.ConfirmAsync` inserts the Confirmed row, which is the consent record (actor, time, digest, proof providers); the page then runs `AdvanceAsync` once (a throw is logged and the sweeper resumes) and sends the member to Merge status. `MergeProofs.Clear` runs after a Confirmed outcome only: Blocked, PreviewStale, NotParty and ProofAccountMismatch come back as closed codes (`?merge=`), keep the proofs and write nothing. + +`MergePreview` is the one wording of the plan (counts by entry kind and action, the nine block codes, the static statements about chat, connections, StreamPixels, roles, one vote per motion, the 7-day undo and ordinary sign-ins); the digest is `MergePlan.Digest` of the plan just rendered. `MergeWording.Confirm` holds the refusal words. The page writes no merge, vote, membership or journal row. + +Deploy: set `AccountMerge__Enabled` only after Heimdall's identity-moves route is deployed and probed. A Confirmed merge against a Heimdall without it stays Unknown and keeps the folded account frozen. + +## Notices + +The chat host learns of merges by pulling, never by being pushed to: Bifrost has no event receiver and no outbox. `MergeNoticeFeed` derives the notices from the `AccountMerges` row on every read (merged: `CommittedUtc` set; undone: `UndoneUtc` set; final: `FinalizedUtc` set), so a notice cannot disagree with its merge, and a Failed, Frozen or Confirmed merge (no `CommittedUtc`) yields none. The only stored fact is the reader's ack, one `AccountMergeNoticeAcks` row per (merge, kind, target), written by nothing else (migration `AddAccountMergeNoticeAcks`; the table holds only acks, so its `Down` drops it). The feed never writes a merge column. + +``` +GET /api/merge-notices/chat?after=&limit= -> 200 JSON array, oldest first by (occurred_at, merge_id, kind) +POST /api/merge-notices/chat/ack {"merge_id": "...", "type": "merged"} -> 204 +``` + +Payload, exactly these keys: `type` (`merged`, `undone`, `final`), `merge_id`, `occurred_at` (the matching timestamp, ISO 8601 UTC), `grace_ends_at`, `old_subject` (the folded `UserAccount.Id`, format D, the OIDC `sub` the chat session knew), `new_subject` (the survivor's), `old_preferred_username`, `new_preferred_username` (`NormalizedBifrostIdentity`). `limit` defaults to 50 and is capped at 200. Acks are the cursor: a read returns every unacked notice; `after` only narrows the scan to notices strictly later than that time, so a reader must not skip unacked ones by it. An ack is idempotent (a repeat is 204); an ack for a (merge, kind) the feed would not emit now, such as `undone` for a merge not undone, is 404; a malformed body or unknown `type` is 400. + +Auth: `Authorization: Bearer `, compared in constant time after SHA-256 of both sides against `ChatNotices:Token` (environment `ChatNotices__Token`). An empty token turns both routes off (404). A missing or wrong bearer is 401 with an empty body. No cookie, no CORS, `Cache-Control: no-store`. Startup does not require the token. + +``` +curl -sS -H "Authorization: Bearer $TOKEN" "$BIFROST/api/merge-notices/chat?limit=50" +curl -sS -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"merge_id":"","type":"merged"}' "$BIFROST/api/merge-notices/chat/ack" +``` + +The feed carries no email, display name, Heimdall id, Matrix id or history flag. It is information for the chat operator's side; back-channel logout (`OidcLogoutReconciler`) stays what ends chat sessions. Chat-side rules that bind Bifrost already hold: undo is refused past Final, and `preferred_username` never changes. A later reader (StreamPixels) adds a target constant and its own token over the same derivation. + +Deploy: set `ChatNotices__Token` on Bifrost and hand the same value to the chat host's timer script (the secret's path is the chat steward's). + +## Merged is terminal + +Only the ledger writes `Membership.Status = Merged` or leaves it. The other status writers refuse a merged row (`Membership.IsMerged`): the bootstrap-admin promotion (startup and every GitHub sign-in, which is the one status write sign-in sync makes on an existing account) skips it, the Heimdall member-access claim does not activate it, member approval refuses it and the suspension service reports `NotApplicable`. Folded standing itself is still derived from the merge state, never from this status. + +## Rollback + +Bifrost is **roll-forward only** once a merge exists (`heimdall-body:ruling:merge-roll-forward-only`). A build older than `AddAccountMerges` cannot read the `Merged` membership status (EF throws on `/App/Members` and on the folded account's sign-in) and has no query filter, so it counts voided votes and appraisals again. Deploy the merge migration knowing that a code rollback past it is not available after the first merge lands; fix forward. The migration's `Down` refuses while any `AccountMerges` row exists, whatever state, so it cannot drop the merge record and journal under a `Merged` membership. No tombstone table is built: it would fix only the recount, not the enum. + +## Not covered + +`MembershipSynchronizationService` still writes the profile of a GitHub-signed-in row that is not merged. Sealed-ballot rows of a folded account on a motion that is already closed stay as they are. Every writer that acts for an account (votes, concept appraisals, sealed-ballot commit and reveal, patron events, the Heimdall sign-in's membership) passes through `AccountMergeLedger.WriteAsAsync`. On Postgres it opens a transaction, takes a shared advisory lock on the account (`pg_advisory_xact_lock_shared(hashtextextended(id::text, 0))`, the key the chain trigger takes exclusively when a merge row is inserted or changes state), reads standing, runs the writer's save and commits. `CommitAsync` takes the exclusive lock on the folded account before it reduces, then re-reads the merge. So: a writer whose standing read found no merge saves before a merge can freeze or fold the account, and the commit's reduction sees what it saved; a merge that is already frozen or folded when the writer reads is refused (votes, appraisals, ballots, sign-in membership) or routed (patron events). Guaranteed only for writes made through the gate, and only on Postgres: the in-memory provider has no lock or transaction (tests that rest on the lock run on live Postgres), and a writer that catches a database error inside the gate cannot continue, because the transaction is aborted. Not closed: writers outside the gate (nothing in the code base today; a new one must use it), and a writer's own stale reads taken before the gate opened (the motion a vote was loaded with), which concern the motion, not the account's standing. No operator void tool exists. + +`UX_TierSnapshots_MembershipId_CurrentPatron` allows one current Patron snapshot per membership, so two restages of one survivor racing cannot both leave a current snapshot: the loser's save fails on the unique index, a merge commit rolls back whole and is retried by the saga, and a patron refresh re-reads and derives again. 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/bridge.md b/docs/bridge.md index e899b6c..b4cf73c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,7 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. -Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download//` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT///[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `/` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. An ack posts nothing to Matrix. That Persona's public `m.read` receipt follows `gamecult.persona.chat_read.v1` (`[persona, room_alias, event_id, at_unix_ms]`), which Huginn sends when a delivery has gone into the model's input: the mouth posts `m.read` at that event id only, for an event it delivered to that Persona in that room (to the delivered room id, whatever alias the report named), never behind the last receipt that landed there, one report per room at a time, and only while her brake is released (re-read after the presence write; fire and forget on the endpoint: a failure is logged once and not retried; pending polls and acks post none). Her Matrix presence follows a turn and her brake: `online` while a read report, a send or an ack came within `presenceGraceMs` (5 minutes), `unavailable` after that, `offline` while the brake is held. Each `/sync` carries the state as `set_presence`, and a change is also `PUT /presence//status` at once (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed. +Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. v1-v4 delivery ids are Matrix ids: the event id (`$`), the sender, each context line's sender and each mention (`@`) are at most 255 bytes with no whitespace, control, format or private-use characters; the mouth skips an event, drops a context or tail line, or drops a mention that fails this, and the encoder and parser refuse it. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download//` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT///[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `/` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. An ack posts nothing to Matrix. That Persona's public `m.read` receipt follows `gamecult.persona.chat_read.v1` (`[persona, room_alias, event_id, at_unix_ms]`), which Huginn sends when a delivery has gone into the model's input: the mouth posts `m.read` at that event id only, for an event it delivered to that Persona in that room (to the delivered room id, whatever alias the report named), never behind the last receipt that landed there, one report per room at a time, and only while her brake is released (re-read after the presence write; fire and forget on the endpoint: a failure is logged once and not retried; pending polls and acks post none). Her Matrix presence follows a turn and her brake: `online` while a read report, a send or an ack came within `presenceGraceMs` (5 minutes), `unavailable` after that, `offline` while the brake is held. Each `/sync` carries the state as `set_presence`, and a change is also `PUT /presence//status` at once (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed. ### Addressing facts (`chat_pending.v3`) @@ -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"`, `"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,33 @@ 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 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` 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. + +### 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), 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 makes at most 8 reply-target reads in all, `basis` reads included (it counts each uncached GET of a reply target or thread root, and an event that would take the round past 8 is not started; the context reads of a delivery, one room or thread each per round, are separate and uncounted); a target the cap check found cached is served to that event for its own reads even if its 60 s keep lapses meanwhile, which is at most one other target read (10 s timeout) late; past that the event 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. + 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. @@ -284,6 +311,10 @@ The intended shape: VoidBot still owns room reading, moderation judgment, archive/source retrieval, and Persona cognition. Bifrost owns Discord when Discord is being used as the Labor Platform interface or swarm transport. +### Fetch a named file (`chat_fetch_request.v1`, `chat_fetch_receipt.v1`) + +A Persona's Mind shell holds no Matrix token, so the mouth keeps a file for her: `chat_fetch_request.v1` is `[request_id, persona, room, event_id]`, a room (alias or id) and an event id, nothing else. The mouth reads her live membership and the room's full state, reads the event itself and takes `content.url` from it, fetches it as her (5 MiB, magic bytes decide the type), compares the file event's `info.sha256` with the hash it computed, stores the bytes in her image store, and answers `chat_fetch_receipt.v1` `[request_id, outcome, room_id, image, receipt]` with one of 14 fixed outcome words. A file with no `info.sha256` is `StoredUnverified` and gets no receipt; a present hash that is not the computed one is `HashMismatch` and stores nothing. For a stored, hash-matching file the mouth posts the contract's `org.gamecult.file_received` event (`{event_id, sha256, size}`) as her own Matrix user, once per (file, Mind): a reply in the album thread, else a plain event with an `m.reference` to the file. The Matrix sender is the Mind; no content key names her. Idempotence is read from Matrix, not kept. Bridged and encrypted rooms and other people's rooms are refused. The kind is served only with `CHAT_FETCH=1` (exactly that value); off, it is answered as any unknown kind (`UnknownKind`). One 15 s deadline owns each fetch, and one Persona may ask 30 in an hour. Each fetch writes one log line, `fetch persona=... outcome=... receipt=...`, and a failure adds a trailing ` cause=/`, on a thrown fetch failure, a store write that throws, or a receipt not proven: `Class` is the error's class name when it matches `[A-Za-z]{1,32}`, else `Error`; `status` is the HTTP status integer of a failed Matrix call, else `-`. No error-derived text (message, code, path, URL) reaches the log; the room and event ids, already shape-checked, are the only ids on the fetch line. `isFetchId` refuses a room-version-3 event id that contains `/` (standard base64), a fail-closed side: it is answered `NoAttachment`. + ## Heimdall Integration Target The correct future credential path is: diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index c20525d..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 @@ -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 ` diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs new file mode 100644 index 0000000..a92e916 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallIdentityMoveClient.cs @@ -0,0 +1,351 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bifrost.Web.Configuration; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// What Bifrost asks Heimdall to move (docs/service-contract.md, POST /v1/apps/{app}/identity-moves). Exactly one of +/// (a forward move, optionally of ) and (the undo). The body +/// never names an account: the proofs, live Heimdall access tokens, say which accounts are meant. +/// +public sealed record HeimdallMoveRequest( + string MoveId, IReadOnlyList FromProofs, string? ToProof = null, IReadOnlyList? IdentityIds = null, string? ReversesMoveId = null) +{ + // The proofs are live bearers: the generated ToString would print them into any log or exception that holds the request. + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"MoveId = {MoveId}, FromProofs = {FromProofs?.Count}, ToProof = {(ToProof is null ? "none" : "present")}, ReversesMoveId = {ReversesMoveId}"); + return true; + } +} + +public enum HeimdallProofTier +{ + Strong, + Weak +} + +/// The receipt of a move: the destination account, the logins that moved and the tier of the proofs that carried it. +public sealed record HeimdallMoveReceipt( + string MoveId, string AccountId, IReadOnlyList MovedIdentityIds, HeimdallProofTier ProofFrom, HeimdallProofTier? ProofTo, string? ReversesMoveId); + +/// Heimdall's fixed refusal codes for a move, plus invalid_request, Heimdall's own schema 400. +public enum HeimdallMoveRefusal +{ + AppAuthRequired, + NotAllowedForApp, + NotReversible, + ProofInvalid, + SameAccount, + ProofStale, + IdentityNotProved, + AlreadyMoved, + ProviderConflict, + IdentityNotFound, + RequestInvalid +} + +public enum HeimdallProofSide +{ + From, + To +} + +/// +/// The closed answer to a move. Moved: the receipt. Refused: Heimdall answered a 4xx before applying anything, so nothing changed. +/// Unknown: anything else (a 5xx may follow a committed move, a timeout may hide one, and so may a 409 move_id_conflict: Heimdall +/// answers it to a replay of a committed move whose proofs no longer verify): the caller keeps its proofs and replays the same request. +/// +public abstract record HeimdallMoveOutcome +{ + private HeimdallMoveOutcome() + { + } + + public sealed record Moved(HeimdallMoveReceipt Receipt) : HeimdallMoveOutcome; + + public sealed record Refused(HeimdallMoveRefusal Code, HeimdallProofSide? Side = null, string? Provider = null) : HeimdallMoveOutcome; + + public sealed record Unknown(string Reason) : HeimdallMoveOutcome; +} + +/// +/// Heimdall's move route under Bifrost's app secret (HeimdallAuthService.ConfigureClient adds the header). Each call sends one request and +/// retries nothing: replay with the same is the caller's, and Heimdall answers it with the stored +/// receipt. Logs name the outcome class and refusal code only. +/// +public sealed partial class HeimdallIdentityMoveClient( + HttpClient httpClient, IOptions heimdallOptions, ILogger logger) +{ + /// The deadline of one call, headers and body together, set on the HttpClient by . + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(15); + + private const int MaxMovedIdentities = 256; + private const int MaxIdentityIdLength = 128; + private const int MaxReceiptBytes = 64 * 1024; + public const int MaxProofLength = 8192; + + // \z, not $: $ also matches before a final line feed, which Heimdall's schema pattern refuses with a 400. + [GeneratedRegex("^[A-Za-z0-9_-]{16,64}\\z")] + private static partial Regex MoveIdShape(); + + public async Task MoveAsync(HeimdallMoveRequest request, CancellationToken cancellationToken) + { + var body = Serialize(request); + var outcome = await SendAsync(request, body, cancellationToken); + logger.LogInformation("Heimdall identity move: {Outcome} {Code}", outcome.GetType().Name, outcome is HeimdallMoveOutcome.Refused refused ? refused.Code : null); + return outcome; + } + + private async Task SendAsync(HeimdallMoveRequest request, string body, CancellationToken cancellationToken) + { + // One deadline over the whole call: HttpClient.Timeout ends at the headers, and a stalled body must not outlive it. + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(httpClient.Timeout); + var token = deadline.Token; + try + { + using var message = new HttpRequestMessage( + HttpMethod.Post, $"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/identity-moves") + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + using var response = await httpClient.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, token); + var status = (int)response.StatusCode; + if (response.StatusCode == HttpStatusCode.OK) + { + return await ReadReceiptAsync(response, request, token) is { } receipt + ? new HeimdallMoveOutcome.Moved(receipt) + : new HeimdallMoveOutcome.Unknown("malformed or mismatched receipt"); + } + + if (status is < 400 or >= 500 || status == 429) + { + return new HeimdallMoveOutcome.Unknown($"HTTP {status}"); + } + + return Refused(await HeimdallRefusal.ReadAsync(response, token), status); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + // Whatever the pipeline throws after the request may have gone out is not a refusal; only the caller's own cancellation propagates. + return new HeimdallMoveOutcome.Unknown("transport"); + } + } + + /// + /// Refused only for the coded refusals Heimdall's move route emits (4fe367a moveIdentitiesRoute, and its error handler's invalid_request 400). A 4xx without one of those codes + /// (a gateway's HTML 404, a 408, a code a later Heimdall adds) proves nothing was applied, so it is Unknown. move_id_conflict is + /// Unknown too: a committed move answers it when its proofs no longer verify. + /// + private static HeimdallMoveOutcome Refused(HeimdallRefusalBody? refusal, int status) + { + if (refusal?.Code == "move_id_conflict") + { + return new HeimdallMoveOutcome.Unknown("move_id_conflict"); + } + + var code = refusal?.Code switch + { + "app_auth_required" => HeimdallMoveRefusal.AppAuthRequired, + "not_allowed_for_app" => HeimdallMoveRefusal.NotAllowedForApp, + "not_reversible" => HeimdallMoveRefusal.NotReversible, + "proof_invalid" => HeimdallMoveRefusal.ProofInvalid, + "same_account" => HeimdallMoveRefusal.SameAccount, + "proof_stale" => HeimdallMoveRefusal.ProofStale, + "identity_not_proved" => HeimdallMoveRefusal.IdentityNotProved, + "already_moved" => HeimdallMoveRefusal.AlreadyMoved, + "provider_conflict" => HeimdallMoveRefusal.ProviderConflict, + "identity_not_found" => HeimdallMoveRefusal.IdentityNotFound, + // Heimdall's error handler gives its own 400 this code; an nginx or other edge 400 has none, so it is not Heimdall's refusal. + "invalid_request" when status == 400 => HeimdallMoveRefusal.RequestInvalid, + _ => (HeimdallMoveRefusal?)null + }; + if (code is not { } known) + { + return new HeimdallMoveOutcome.Unknown($"HTTP {status} without a Heimdall refusal code"); + } + + return known switch + { + HeimdallMoveRefusal.ProofInvalid or HeimdallMoveRefusal.ProofStale => new HeimdallMoveOutcome.Refused(known, Side: refusal?.Proof switch + { + "from" => HeimdallProofSide.From, + "to" => HeimdallProofSide.To, + _ => null + }), + HeimdallMoveRefusal.ProviderConflict => new HeimdallMoveOutcome.Refused(known, Provider: refusal?.Provider), + _ => new HeimdallMoveOutcome.Refused(known) + }; + } + + /// + /// A 200 body is a receipt only when it is whole and answers this request: its moveId (and reversesMoveId) equal the request's, + /// a forward move names a destination proof tier and a reversal does not. Anything else is not trusted as a move. + /// + private static async Task ReadReceiptAsync(HttpResponseMessage response, HeimdallMoveRequest request, CancellationToken cancellationToken) + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var buffer = new MemoryStream(); + var chunk = new byte[4096]; + while (await stream.ReadAsync(chunk, cancellationToken) is > 0 and var read) + { + if (buffer.Length + read > MaxReceiptBytes) + { + return null; + } + + buffer.Write(chunk, 0, read); + } + + using var document = JsonDocument.Parse(buffer.ToArray()); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || Text(root, "moveId") != request.MoveId + || Text(root, "reversesMoveId") != request.ReversesMoveId + || Text(root, "accountId") is not { Length: > 0 } account + || !root.TryGetProperty("movedIdentityIds", out var moved) + || moved.ValueKind != JsonValueKind.Array + || moved.GetArrayLength() is 0 or > MaxMovedIdentities + || !root.TryGetProperty("proof", out var proof) + || proof.ValueKind != JsonValueKind.Object + || Tier(proof, "from") is not { } from) + { + return null; + } + + var ids = new List(); + foreach (var item in moved.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || item.GetString() is not { Length: > 0 and <= MaxIdentityIdLength }) + { + return null; + } + + ids.Add(item.GetString()!); + } + + HeimdallProofTier? to = null; + if (request.ReversesMoveId is null) + { + to = Tier(proof, "to"); + if (to is null) + { + return null; + } + } + else if (!proof.TryGetProperty("to", out var none) || none.ValueKind != JsonValueKind.Null) + { + return null; + } + + return new HeimdallMoveReceipt(request.MoveId, account, ids, from, to, request.ReversesMoveId); + } + + private static HeimdallProofTier? Tier(JsonElement proof, string name) => Text(proof, name) switch + { + "strong" => HeimdallProofTier.Strong, + "weak" => HeimdallProofTier.Weak, + _ => null + }; + + private static string? Text(JsonElement item, string name) => + item.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static bool IsBadProof(string? proof) => string.IsNullOrEmpty(proof) || proof.Length > MaxProofLength; + + /// The contract's closed body, or ArgumentException before any I/O when the request breaks it. + private static string Serialize(HeimdallMoveRequest request) + { + if (request.MoveId is null || !MoveIdShape().IsMatch(request.MoveId)) + { + throw new ArgumentException("moveId must match ^[A-Za-z0-9_-]{16,64}$", nameof(request)); + } + + if (request.FromProofs is not { Count: >= 1 and <= 8 } || request.FromProofs.Any(IsBadProof)) + { + throw new ArgumentException("fromProofs must hold 1 to 8 proofs", nameof(request)); + } + + if ((request.ToProof is null) == (request.ReversesMoveId is null)) + { + throw new ArgumentException("exactly one of toProof and reversesMoveId is required", nameof(request)); + } + + if (request.ToProof is not null && IsBadProof(request.ToProof)) + { + throw new ArgumentException("toProof is empty", nameof(request)); + } + + if (request.ReversesMoveId is not null && !MoveIdShape().IsMatch(request.ReversesMoveId)) + { + throw new ArgumentException("reversesMoveId must match the moveId shape", nameof(request)); + } + + if (request.IdentityIds is not null + && (request.ToProof is null || request.IdentityIds.Count is < 1 or > 32 || request.IdentityIds.Any(string.IsNullOrEmpty) + || request.IdentityIds.Distinct().Count() != request.IdentityIds.Count)) + { + throw new ArgumentException("identityIds are 1 to 32 unique ids and only with toProof", nameof(request)); + } + + var body = new Dictionary { ["moveId"] = request.MoveId, ["fromProofs"] = request.FromProofs }; + if (request.ToProof is not null) + { + body["toProof"] = request.ToProof; + } + + if (request.IdentityIds is not null) + { + body["identityIds"] = request.IdentityIds; + } + + if (request.ReversesMoveId is not null) + { + body["reversesMoveId"] = request.ReversesMoveId; + } + + return JsonSerializer.Serialize(body); + } +} + +public static class HeimdallClientServices +{ + /// + /// Every Heimdall client Bifrost owns. The auth client carries the app secret, the member client only the member's bearer and + /// the mover the app secret and its call deadline; all share the header redaction. + /// + public static void AddHeimdallClients(this IServiceCollection services) + { + services.AddHttpClient((serviceProvider, client) => + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .WithoutRedirects() + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); + services.AddHttpClient((serviceProvider, client) => + HeimdallMemberClient.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .WithoutRedirects() + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); + services.AddHeimdallIdentityMoveClient(); + } + + /// A 3xx from Heimdall is its answer: following it would carry the app secret or a member's bearer to another origin. + public static IHttpClientBuilder WithoutRedirects(this IHttpClientBuilder builder) => + builder.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2) }); + + /// + /// The mover's HttpClient: Heimdall's base address and app secret from HeimdallAuthService.ConfigureClient (the one secret path), the + /// call deadline, no redirects (a 3xx must never carry the secret or the proofs to another origin), and the shared header redaction. + /// + public static IHttpClientBuilder AddHeimdallIdentityMoveClient(this IServiceCollection services) => + services.AddHttpClient((serviceProvider, client) => + { + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value); + client.Timeout = HeimdallIdentityMoveClient.Timeout; + }) + .WithoutRedirects() + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +} 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/HeimdallPatronSupportIntakeService.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs index 2e95ff3..02b41c2 100644 --- a/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallPatronSupportIntakeService.cs @@ -64,23 +64,22 @@ public sealed class HeimdallPatronSupportIntakeService( x.ProviderEventId == request.ProviderEventId.Trim(), cancellationToken); - if (existingEvent is not null) - { - return HeimdallPatronSupportIntakeResult.Processed( - $"Already processed {request.Provider} patron support event {request.ProviderEventId}.", - existingEvent.Id); - } - var user = await dbContext.UserAccounts .AsNoTracking() .SingleOrDefaultAsync(x => x.HeimdallAccountId == request.HeimdallAccountId.Trim(), cancellationToken); if (user is null) { - return HeimdallPatronSupportIntakeResult.BadRequest( - $"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}."); + return existingEvent is not null + ? HeimdallPatronSupportIntakeResult.Processed( + $"Already processed {request.Provider} patron support event {request.ProviderEventId}.", + existingEvent.Id) + : HeimdallPatronSupportIntakeResult.BadRequest( + $"No Bifrost account is linked to Heimdall account {request.HeimdallAccountId}."); } + // The write gate behind RecordSupportEventAsync routes a folded account's event to its survivor and journals it, and on a + // provider retry returns the stored event, journalling it if it sits on the survivor and was left unjournalled. var supportEvent = await patronageService.RecordSupportEventAsync( actorUserAccountId: null, userAccountId: user.Id, @@ -98,7 +97,9 @@ public sealed class HeimdallPatronSupportIntakeService( cancellationToken); return HeimdallPatronSupportIntakeResult.Processed( - $"Processed {request.Provider} patron support event {request.ProviderEventId}.", + existingEvent is not null + ? $"Already processed {request.Provider} patron support event {request.ProviderEventId}." + : $"Processed {request.Provider} patron support event {request.ProviderEventId}.", supportEvent.Id); } diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs new file mode 100644 index 0000000..c6bd638 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallRefusal.cs @@ -0,0 +1,58 @@ +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) => + (await ReadAsync(response, cancellationToken))?.Code; + + /// + /// The error code plus the two qualifiers a move refusal carries (the contract's `proof`, only "from" or "to", and `provider`, a + /// short snake_case token), each kept only when it is that safe shape. Nothing else of the body is read. Null when no code reads. + /// + public static async Task ReadAsync(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 && Token(body.RootElement, "error", 64) is { } code) + { + var proof = body.RootElement.TryGetProperty("proof", out var side) && side.ValueKind == JsonValueKind.String ? side.GetString() : null; + return new HeimdallRefusalBody(code, proof is "from" or "to" ? proof : null, Token(body.RootElement, "provider", 32)); + } + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + } + + return null; + } + + private static string? Token(JsonElement root, string name, int maxLength) => + root.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String + && value.GetString() is { Length: > 0 } text + && text.Length <= maxLength + && text.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '_') + ? text + : null; +} + +/// A Heimdall refusal's safe fields: the code, the proof side ("from"/"to") and the provider, when present and well formed. +public sealed record HeimdallRefusalBody(string Code, string? Proof, string? Provider); 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/Features/Membership/AccountMergeLedger.cs b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs new file mode 100644 index 0000000..24a61ef --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs @@ -0,0 +1,768 @@ +using System.Globalization; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Patronage; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Membership; + +/// How an account stands under a merge: the folded account of an unfinished merge is Frozen, of a landed one Folded. +public abstract record MergeStanding(Guid MergeId) +{ + public sealed record Frozen(Guid MergeId) : MergeStanding(MergeId); + + public sealed record Folded(Guid MergeId, Guid SurvivorId) : MergeStanding(MergeId); +} + +public sealed record MergePlanEntry( + AccountMergeEntryKind Kind, + AccountMergeEntryAction Action, + Guid RecordId, + Guid? PriorUserAccountId = null, + string? PriorText = null, + DateTimeOffset? PriorAtUtc = null); + +/// A dry run: the block codes (never account names), the journal entries a commit would write, and the record counts left untouched. +public sealed record MergePlan( + IReadOnlyList Blocks, + IReadOnlyList Entries, + IReadOnlyDictionary Left) +{ + /// + /// Lowercase hex SHA-256 over a canonical text of the plan, in plan order (Left by key): what the member saw, comparable at confirm. + /// Every field is length-prefixed, so no value can imitate a separator. The plan holds no account ids of its own, so the caller + /// names the pair the plan was made for. + /// + public string Digest(Guid survivorId, Guid foldedId) + { + var text = new System.Text.StringBuilder(); + void Field(string? value) => text.Append(value is null ? "-" : $"{value.Length}:{value}").Append('|'); + Field(survivorId.ToString("N")); + Field(foldedId.ToString("N")); + foreach (var entry in Entries) + { + Field(entry.Kind.ToString()); + Field(entry.Action.ToString()); + // A Created row does not exist yet: its id is minted anew by every plan, so it cannot be part of what the member saw. + // Left unpinned on purpose: the mutant that keeps the id in (digest-includes-created-id in tools/merge-saga-mutants.py) is behaviourally equivalent bar one benign case. + Field(entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N")); + Field(entry.PriorUserAccountId?.ToString("N")); + Field(entry.PriorText); + Field(entry.PriorAtUtc?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + text.Append('\n'); + } + + text.Append("left\n"); + foreach (var (key, count) in Left.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + Field(key); + Field(count.ToString(CultureInfo.InvariantCulture)); + } + + text.Append("\nblocks\n"); + foreach (var block in Blocks) + { + Field(block); + } + + return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text.ToString()))).ToLowerInvariant(); + } +} + +/// +/// The one owner of what an account merge does to Bifrost records and of how an account stands under one: the plan, the Committed +/// save, the undo save, the Final transition and StandingAsync. A merge only reduces: nothing is deleted, votes and +/// appraisals are stamped void (the model's query filter then removes them from every tally) or re-pointed, and every change is +/// journalled in AccountMergeEntry. Design: /opt/commons/handoff/heimdall-account-merge-design-20261113.md sections 3, 5, 7. +/// +public sealed class AccountMergeLedger( + BifrostDbContext dbContext, + PatronageService patronageService, + TimeProvider timeProvider) +{ + public static readonly TimeSpan Grace = TimeSpan.FromDays(7); + + private static readonly AccountMergeState[] FrozenStates = [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved]; + + private static readonly AccountMergeState[] FoldedStates = + [AccountMergeState.Committed, AccountMergeState.Final, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed]; + + private static readonly AccountMergeState[] InFlightStates = + [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed]; + + public static string SuspensionReason(Guid mergeId) => $"merged with a suspended account (merge {mergeId})"; + + public Task StandingAsync(Guid userAccountId, CancellationToken cancellationToken) => + StandingAsync(dbContext, userAccountId, cancellationToken); + + /// The single reading of standing, shared by the actor accessor and the OIDC refusal, which holds only a context. Derived from the merge state on every call. + public static async Task StandingAsync(BifrostDbContext db, Guid userAccountId, CancellationToken cancellationToken) + { + var merge = await db.AccountMerges.AsNoTracking() + .Where(x => x.FoldedUserAccountId == userAccountId && + (FrozenStates.Contains(x.State) || FoldedStates.Contains(x.State))) + .OrderByDescending(x => x.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + + return merge is null + ? null + : FrozenStates.Contains(merge.State) + ? new MergeStanding.Frozen(merge.Id) + : new MergeStanding.Folded(merge.Id, merge.SurvivorUserAccountId); + } + + /// + /// The one gate every writer that acts for an account passes through (votes, appraisals, sealed ballots, patron events, the Heimdall + /// sign-in's membership). On Postgres it holds a shared advisory lock on the account, the key the chain trigger locks exclusively + /// when a merge row is inserted or changes state and that takes exclusively before it reduces, across the + /// standing read and everything saves, then commits. So a merge cannot freeze or fold the account between a + /// writer's standing read and its save: it waits for the writer, and a writer that starts after it reads the new standing. The + /// lock covers writes made through this gate and nothing else; it is held until the transaction ends, so a writer must not + /// catch a database error inside and carry on (the transaction is already aborted). On the in-memory + /// provider there is no lock and no transaction: the standing is read, then the writer saves. + /// + public static async Task WriteAsAsync(BifrostDbContext db, Guid userAccountId, Func> write, CancellationToken cancellationToken) + { + await using var transaction = await BeginAsync(db, cancellationToken); + await LockAsync(db, userAccountId, exclusive: false, cancellationToken); + var result = await write(await StandingAsync(db, userAccountId, cancellationToken)); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + return result; + } + + /// A transaction for the caller to commit, or null where there is none to open: not Postgres, or one already open (its owner commits). + private static async Task BeginAsync(BifrostDbContext db, CancellationToken cancellationToken) => + db.Database.IsNpgsql() && db.Database.CurrentTransaction is null ? await db.Database.BeginTransactionAsync(cancellationToken) : null; + + /// The account's lock, on the key the chain trigger uses (hashtextextended of the id's text). A no-op off Postgres. + private static async Task LockAsync(BifrostDbContext db, Guid userAccountId, bool exclusive, CancellationToken cancellationToken) + { + if (!db.Database.IsNpgsql()) + { + return; + } + + var key = userAccountId.ToString(); + await (exclusive + ? db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock(hashtextextended({key}, 0))", cancellationToken) + : db.Database.ExecuteSqlInterpolatedAsync($"SELECT pg_advisory_xact_lock_shared(hashtextextended({key}, 0))", cancellationToken)); + } + + /// Writes nothing. lets the saga re-plan a merge it already holds without it blocking itself. + public async Task PlanAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? exceptMergeId = null) + { + var blocks = await BlocksAsync(survivorId, foldedId, exceptMergeId, cancellationToken); + var entries = new List(); + var left = new Dictionary(); + + if (!blocks.Contains("account_missing")) + { + var reduction = await ReduceAsync(survivorId, foldedId, cancellationToken); + entries.AddRange(reduction.Entries); + left["ConsentReceipts"] = await dbContext.ConsentReceipts.CountAsync(x => x.UserAccountId == foldedId, cancellationToken); + left["PasskeyCredentials"] = await dbContext.PasskeyCredentials.CountAsync(x => x.UserAccountId == foldedId, cancellationToken); + left["LedgerEntries"] = await dbContext.LedgerEntries.CountAsync(x => x.UserAccountId == foldedId, cancellationToken); + } + + return new MergePlan(blocks, entries, left); + } + + private async Task> BlocksAsync(Guid survivorId, Guid foldedId, Guid? exceptMergeId, CancellationToken cancellationToken) + { + var blocks = new List(); + if (survivorId == foldedId) + { + blocks.Add("same_account"); + } + + var accounts = await dbContext.UserAccounts.AsNoTracking() + .Where(x => x.Id == survivorId || x.Id == foldedId) + .Select(x => new { x.Id, x.HeimdallAccountId }) + .ToListAsync(cancellationToken); + if (accounts.Count < (survivorId == foldedId ? 1 : 2)) + { + blocks.Add("account_missing"); + return blocks; + } + + if (await dbContext.MindPersonas.AnyAsync(x => x.UserAccountId == survivorId || x.UserAccountId == foldedId, cancellationToken)) + { + blocks.Add("mind_account"); + } + + if (accounts.Any(x => string.IsNullOrWhiteSpace(x.HeimdallAccountId))) + { + blocks.Add("native_account"); + } + + if (await dbContext.AccountMerges.AnyAsync( + x => x.Id != exceptMergeId && + InFlightStates.Contains(x.State) && + (x.SurvivorUserAccountId == survivorId || x.FoldedUserAccountId == survivorId || + x.SurvivorUserAccountId == foldedId || x.FoldedUserAccountId == foldedId), + cancellationToken)) + { + blocks.Add("merge_in_flight"); + } + + if (await FoldedLinkAsync(survivorId, exceptMergeId, cancellationToken)) + { + blocks.Add("survivor_is_folded"); + } + + if (await FoldedLinkAsync(foldedId, exceptMergeId, cancellationToken)) + { + blocks.Add("already_folded"); + } + + // One hop only: a landed merge's survivor never folds into a third account, so an alias is never a chain. + if (await dbContext.AccountMerges.AnyAsync( + x => x.Id != exceptMergeId && FoldedStates.Contains(x.State) && x.SurvivorUserAccountId == foldedId, + cancellationToken)) + { + blocks.Add("folded_is_survivor"); + } + + if (await dbContext.SealedBallots.AnyAsync( + x => x.UserAccountId == foldedId && x.Motion.Status == MotionStatus.Open, + cancellationToken)) + { + blocks.Add("sealed_ballot_pending"); + } + + return blocks; + } + + private Task FoldedLinkAsync(Guid accountId, Guid? exceptMergeId, CancellationToken cancellationToken) => + dbContext.AccountMerges.AnyAsync( + x => x.Id != exceptMergeId && x.FoldedUserAccountId == accountId && FoldedStates.Contains(x.State), + cancellationToken); + + /// + /// Applies the merge to Bifrost records in one save, together with State=Committed. Total: blocks were checked before the move + /// and a refusal now would strand moved logins, so it recomputes the reductions from current rows and applies them. Committed + /// and Final are no-ops returning the receipt; any other state but HeimdallMoved is an error. + /// + public async Task CommitAsync(Guid mergeId, CancellationToken cancellationToken) + { + var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken); + + // The exclusive lock first, so no writer that read the folded account's standing is still to save when the reduction reads its + // rows, then the state again: a commit that waited here found the merge landed and returns its receipt. + await using var transaction = await BeginAsync(dbContext, cancellationToken); + await LockAsync(dbContext, merge.FoldedUserAccountId, exclusive: true, cancellationToken); + await dbContext.Entry(merge).ReloadAsync(cancellationToken); + if (merge.State is AccountMergeState.Committed or AccountMergeState.Final) + { + return merge; + } + + if (merge.State != AccountMergeState.HeimdallMoved) + { + throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallMoved merge can be committed."); + } + + try + { + var reduction = await ReduceAsync(merge.SurvivorUserAccountId, merge.FoldedUserAccountId, cancellationToken, mergeId); + var now = timeProvider.GetUtcNow(); + var seq = 0; + foreach (var entry in reduction.Entries) + { + dbContext.AccountMergeEntries.Add(Journal(merge.Id, ++seq, entry, now)); + } + + reduction.Apply(now); + if (reduction.MovedPatronEvents) + { + await RestageTiersAsync(reduction.Survivor, reduction.Folded, now, cancellationToken); + } + + reduction.ReweighMovedVotes(); + merge.State = AccountMergeState.Committed; + merge.CommittedUtc = now; + merge.GraceEndsUtc = now + Grace; + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + + return merge; + } + catch + { + // Nothing was saved; drop what was staged so a retry on this context starts from the stored rows. + dbContext.ChangeTracker.Clear(); + throw; + } + } + + /// Replays the journal backward in one save with State=Undone. Total, like commit. Undone is a no-op; any other state but HeimdallReversed is an error. + public async Task UndoLocalAsync(Guid mergeId, CancellationToken cancellationToken) + { + var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == mergeId, cancellationToken); + if (merge.State == AccountMergeState.Undone) + { + return merge; + } + + if (merge.State != AccountMergeState.HeimdallReversed) + { + throw new InvalidOperationException($"Merge {mergeId} is {merge.State}; only a HeimdallReversed merge can be undone locally."); + } + + if (merge.FinalizedUtc is not null) + { + throw new InvalidOperationException($"Merge {mergeId} is Final; a merge is never undone past Final."); + } + + try + { + var now = timeProvider.GetUtcNow(); + var journal = await dbContext.AccountMergeEntries.Where(x => x.MergeId == mergeId).OrderByDescending(x => x.Seq).ToListAsync(cancellationToken); + var seq = journal.Count == 0 ? 0 : journal.Max(x => x.Seq); + var survivor = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == merge.SurvivorUserAccountId, cancellationToken); + var folded = await dbContext.UserAccounts.Include(x => x.Membership).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == merge.FoldedUserAccountId, cancellationToken); + var survivorKey = AppraisalKey(survivor.Id); + var restagePatron = false; + + void Record(AccountMergeEntry original, bool restored) => + dbContext.AccountMergeEntries.Add(new AccountMergeEntry + { + MergeId = mergeId, + Seq = ++seq, + Kind = original.Kind, + RecordId = original.RecordId, + Action = restored ? AccountMergeEntryAction.Restored : AccountMergeEntryAction.KeptAfterUndo, + PriorUserAccountId = original.PriorUserAccountId, + AtUtc = now + }); + + foreach (var entry in journal.Where(x => x.Action is AccountMergeEntryAction.Voided or AccountMergeEntryAction.Moved or AccountMergeEntryAction.Closed or AccountMergeEntryAction.Suspended or AccountMergeEntryAction.Created)) + { + switch (entry.Kind, entry.Action) + { + case (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided): + { + var vote = await dbContext.Votes.IgnoreQueryFilters().Include(x => x.Motion).SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = vote?.VoidedByMergeId == mergeId && vote.Motion.Status == MotionStatus.Open; + if (restore) + { + vote!.VoidedByMergeId = null; + vote.VoidedUtc = null; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved): + { + var vote = await dbContext.Votes.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = vote is not null && vote.UserAccountId == survivor.Id && vote.CastAtUtc == entry.PriorAtUtc; + if (restore) + { + vote!.UserAccountId = entry.PriorUserAccountId!.Value; + if (decimal.TryParse(entry.PriorText, NumberStyles.Number, CultureInfo.InvariantCulture, out var priorWeight)) + { + vote.Weight = priorWeight; + } + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided): + { + var row = await dbContext.ConceptAppraisals.IgnoreQueryFilters().SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = row?.VoidedByMergeId == mergeId; + if (restore) + { + row!.VoidedByMergeId = null; + row.VoidedUtc = null; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved): + { + var row = await dbContext.ConceptAppraisals.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = row is not null && row.UserAccountId == survivor.Id && row.VoterKey == survivorKey && row.CastAtUtc == entry.PriorAtUtc; + if (restore) + { + row!.UserAccountId = entry.PriorUserAccountId!.Value; + var (priorClass, priorKey) = ParseAppraisalPrior(entry.PriorText); + row.VoterKey = priorKey ?? row.VoterKey; + row.VoterClass = priorClass ?? row.VoterClass; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed): + { + var restore = folded.Membership is { Status: MembershipStatus.Merged } && Enum.TryParse(entry.PriorText, out _); + if (restore) + { + folded.Membership!.Status = Enum.Parse(entry.PriorText!); + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended): + { + // Only while the survivor still carries exactly this merge's suspension; an admin who changed it since owns the standing now. + var restore = survivor.Membership is { Status: MembershipStatus.Suspended } member && + member.SuspensionReason == SuspensionReason(mergeId) && + Enum.TryParse(entry.PriorText, out _); + if (restore) + { + survivor.Membership!.Status = Enum.Parse(entry.PriorText!); + survivor.Membership.SuspensionReason = null; + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created): + { + // The membership this merge made to carry a suspension goes with the merge, unless an admin has taken it over since. + var created = await dbContext.Memberships.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = created is { Status: MembershipStatus.Suspended } && created.SuspensionReason == SuspensionReason(mergeId); + if (restore) + { + dbContext.Memberships.Remove(created!); + } + + Record(entry, restore); + break; + } + case (AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved): + { + var row = await dbContext.PatronSupportEvents.SingleOrDefaultAsync(x => x.Id == entry.RecordId, cancellationToken); + var restore = row is not null && row.UserAccountId == survivor.Id; + if (restore) + { + row!.UserAccountId = entry.PriorUserAccountId!.Value; + restagePatron = true; + } + + Record(entry, restore); + break; + } + } + } + + if (restagePatron) + { + await RestageTiersAsync(survivor, folded, now, cancellationToken); + } + + merge.State = AccountMergeState.Undone; + merge.UndoneUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + return merge; + } + catch + { + dbContext.ChangeTracker.Clear(); + throw; + } + } + + /// + /// Committed merges whose grace has ended become Final; returns how many this call finalized. Undo is never allowed past Final. + /// State is the merge's concurrency token, so a merge another writer moved first (UndoRequested) fails its save and is left to that + /// transition: a stale Committed read can never overwrite it. + /// + public async Task FinalizeDueAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var dueIds = await dbContext.AccountMerges.AsNoTracking() + .Where(x => x.State == AccountMergeState.Committed && x.GraceEndsUtc <= now) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + var finalized = 0; + foreach (var id in dueIds) + { + var merge = await dbContext.AccountMerges.SingleAsync(x => x.Id == id, cancellationToken); + if (merge.State != AccountMergeState.Committed) + { + continue; + } + + merge.State = AccountMergeState.Final; + merge.FinalizedUtc = now; + try + { + await dbContext.SaveChangesAsync(cancellationToken); + finalized++; + } + catch (DbUpdateConcurrencyException) + { + dbContext.ChangeTracker.Clear(); + } + } + + return finalized; + } + + /// + /// Stages the journal entry for a patron event that is on a folded account's survivor, so undo moves it back with the rest; saved + /// with the event by , the only caller. Idempotent: a provider retry stages nothing. + /// + public static async Task StageIntakeJournalAsync(BifrostDbContext db, Guid mergeId, Guid foldedUserAccountId, Guid supportEventId, DateTimeOffset now, CancellationToken cancellationToken) + { + if (await db.AccountMergeEntries.AnyAsync( + x => x.MergeId == mergeId && x.Kind == AccountMergeEntryKind.PatronSupportEvent && x.RecordId == supportEventId, + cancellationToken)) + { + return; + } + + var seq = await db.AccountMergeEntries.Where(x => x.MergeId == mergeId).MaxAsync(x => (int?)x.Seq, cancellationToken) ?? 0; + db.AccountMergeEntries.Add(Journal( + mergeId, + seq + 1, + new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEventId, foldedUserAccountId), + now)); + } + + private sealed class Reduction( + UserAccount survivor, + UserAccount folded, + List entries, + List> effects, + List movedVotes, + bool movedPatronEvents) + { + public UserAccount Survivor { get; } = survivor; + + public UserAccount Folded { get; } = folded; + + public IReadOnlyList Entries => entries; + + public bool MovedPatronEvents { get; } = movedPatronEvents; + + public Guid MergeId { get; set; } + + /// A moved vote carries the survivor's weight, derived once from the survivor's tiers as they stand after the restage, never the folded account's. + public void ReweighMovedVotes() + { + foreach (var vote in movedVotes) + { + vote.Weight = Survivor.Membership!.EffectiveVotingWeight; + } + } + + public void Apply(DateTimeOffset now) + { + foreach (var effect in effects) + { + effect(MergeId, now); + } + } + } + + /// + /// What a merge does to records, computed from current rows without writing: the journal entries and the effects that apply them. + /// Rules (design 5): suspension wins; the folded membership is closed and its roles never copied; open standard motions and + /// concept appraisals keep one voice per person (the survivor's), a lone folded one moving only to an Active survivor; patron + /// events follow the person. + /// + private async Task ReduceAsync(Guid survivorId, Guid foldedId, CancellationToken cancellationToken, Guid? mergeId = null) + { + var survivor = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == survivorId, cancellationToken); + var folded = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == foldedId, cancellationToken); + + var entries = new List(); + var effects = new List>(); + var movedVotes = new List(); + + var foldedSuspended = folded.Membership?.Status == MembershipStatus.Suspended; + var survivorSuspendedNow = survivor.Membership?.Status == MembershipStatus.Suspended; + var survivorStanding = survivor.Membership?.Status; + if (foldedSuspended && !survivorSuspendedNow) + { + if (survivor.Membership is { } survivorMembership) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended, survivorMembership.Id, null, survivorMembership.Status.ToString())); + effects.Add((id, _) => + { + survivorMembership.Status = MembershipStatus.Suspended; + survivorMembership.SuspensionReason = SuspensionReason(id); + }); + } + else + { + // The chat tier holds no membership, and an absent one chats: without a row the suspension would be gone. The row is journalled so undo removes it. + var created = new Bifrost.Web.Domain.Membership { UserAccountId = survivor.Id, Status = MembershipStatus.Suspended }; + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Created, created.Id)); + effects.Add((id, now) => + { + created.SuspensionReason = SuspensionReason(id); + created.CreatedAtUtc = now; + created.Notes = "Created by an account merge to carry the folded account's suspension."; + dbContext.Memberships.Add(created); + }); + } + + survivorStanding = MembershipStatus.Suspended; + } + + var survivorActive = survivorStanding == MembershipStatus.Active; + + if (folded.Membership is { } foldedMembership) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed, foldedMembership.Id, null, foldedMembership.Status.ToString())); + effects.Add((_, _) => foldedMembership.Status = MembershipStatus.Merged); + } + + var foldedVotes = await dbContext.Votes + .Where(x => x.UserAccountId == foldedId && x.Motion.Status == MotionStatus.Open && x.Motion.Kind == MotionKind.Standard) + .ToListAsync(cancellationToken); + var survivorVotedMotions = (await dbContext.Votes + .Where(x => x.UserAccountId == survivorId && x.Motion.Status == MotionStatus.Open && x.Motion.Kind == MotionKind.Standard) + .Select(x => x.MotionId) + .ToListAsync(cancellationToken)).ToHashSet(); + foreach (var vote in foldedVotes) + { + if (survivorVotedMotions.Contains(vote.MotionId) || !survivorActive) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided, vote.Id, vote.UserAccountId)); + effects.Add((id, now) => + { + vote.VoidedByMergeId = id; + vote.VoidedUtc = now; + }); + } + else + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved, vote.Id, vote.UserAccountId, vote.Weight.ToString(CultureInfo.InvariantCulture), vote.CastAtUtc)); + movedVotes.Add(vote); + effects.Add((_, _) => vote.UserAccountId = survivorId); + } + } + + var survivorKey = AppraisalKey(survivorId); + var foldedAppraisals = await dbContext.ConceptAppraisals.Where(x => x.UserAccountId == foldedId).ToListAsync(cancellationToken); + var survivorSubjects = (await dbContext.ConceptAppraisals + .Where(x => x.UserAccountId == survivorId || x.VoterKey == survivorKey) + .Select(x => x.SubjectId) + .ToListAsync(cancellationToken)).ToHashSet(); + foreach (var appraisal in foldedAppraisals) + { + if (survivorSubjects.Contains(appraisal.SubjectId) || !survivorActive) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided, appraisal.Id, appraisal.UserAccountId)); + effects.Add((id, now) => + { + appraisal.VoidedByMergeId = id; + appraisal.VoidedUtc = now; + }); + } + else + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved, appraisal.Id, appraisal.UserAccountId, $"{appraisal.VoterClass}|{appraisal.VoterKey}", appraisal.CastAtUtc)); + effects.Add((_, _) => + { + appraisal.UserAccountId = survivorId; + appraisal.VoterKey = survivorKey; + appraisal.VoterClass = VoterClass.Member; // the survivor is Active here, which is what makes an account a Member voter + }); + } + } + + var foldedEvents = folded.PatronSupportEvents.ToList(); + foreach (var supportEvent in foldedEvents) + { + entries.Add(new MergePlanEntry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved, supportEvent.Id, supportEvent.UserAccountId)); + effects.Add((_, _) => supportEvent.UserAccountId = survivorId); + } + + var reduction = new Reduction(survivor, folded, entries, effects, movedVotes, foldedEvents.Count > 0); + if (mergeId is { } m) + { + reduction.MergeId = m; + } + + return reduction; + } + + // Tiers derive once from all the events now on each account, never tier A plus tier B. Ownership is read from the + // tracked rows, so it holds before the save as well as after it. + private async Task RestageTiersAsync(UserAccount survivor, UserAccount folded, DateTimeOffset now, CancellationToken cancellationToken) + { + var events = survivor.PatronSupportEvents.Concat(folded.PatronSupportEvents).DistinctBy(x => x.Id).ToList(); + await StageIfMembershipAsync(survivor.Membership, events.Where(x => x.UserAccountId == survivor.Id).ToList(), now, cancellationToken); + await StageIfMembershipAsync(folded.Membership, events.Where(x => x.UserAccountId == folded.Id).ToList(), now, cancellationToken); + } + + private async Task StageIfMembershipAsync(Bifrost.Web.Domain.Membership? membership, IEnumerable events, DateTimeOffset now, CancellationToken cancellationToken) + { + if (membership is not null && dbContext.Entry(membership).State != EntityState.Deleted) + { + await patronageService.StageTierSnapshotAsync(membership, events, now, cancellationToken); + } + } + + private static AccountMergeEntry Journal(Guid mergeId, int seq, MergePlanEntry entry, DateTimeOffset now) => new() + { + MergeId = mergeId, + Seq = seq, + Kind = entry.Kind, + RecordId = entry.RecordId, + Action = entry.Action, + PriorUserAccountId = entry.PriorUserAccountId, + PriorText = entry.PriorText, + PriorAtUtc = entry.PriorAtUtc, + AtUtc = now + }; + + private static string AppraisalKey(Guid accountId) => "account:" + accountId.ToString("N"); + + private static (VoterClass? Class, string? Key) ParseAppraisalPrior(string? prior) + { + var split = prior?.IndexOf('|') ?? -1; + return split > 0 && Enum.TryParse(prior![..split], out var voterClass) ? (voterClass, prior[(split + 1)..]) : (null, null); + } +} + +/// +/// The one merge loop, every 30 seconds: the proof holder drops expired bearers, the saga advances every due in-flight merge (confirm, +/// replay, undo), then the ledger finalizes merges past their grace. A new scope per tick, a warning on failure, the SealedBallotCloser shape. No second merge hosted service exists. +/// +public sealed class AccountMergeSweeper(IServiceScopeFactory scopes, TimeProvider clock, ILogger logger) : BackgroundService +{ + public static readonly TimeSpan Tick = TimeSpan.FromSeconds(30); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(Tick); + do + { + try + { + using var scope = scopes.CreateScope(); + var now = clock.GetUtcNow(); + scope.ServiceProvider.GetRequiredService().PruneExpired(); + await scope.ServiceProvider.GetRequiredService().AdvanceDueAsync(now, stoppingToken); + await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(now, stoppingToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + logger.LogWarning(error, "Account merge sweep failed; retrying next tick."); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } +} diff --git a/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs new file mode 100644 index 0000000..153ee80 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs @@ -0,0 +1,576 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace Bifrost.Web.Features.Membership; + +/// +/// A live Heimdall bearer a member redeemed from a fresh sign-in, with the Heimdall account and provider it came through. The token is a +/// secret: never prints it (nor the account id), so no log or exception that holds a proof can leak it. +/// +public sealed record MergeProof(string AccessToken, string HeimdallAccountId, string Provider) +{ + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"Provider = {Provider}"); + return true; + } +} + +/// What the member confirmed: the pair, the plan digest they saw, and a proof for every login of each side that must speak. +public sealed record ConfirmMergeRequest( + Guid ActorUserAccountId, + Guid SurvivorUserAccountId, + Guid FoldedUserAccountId, + string PreviewDigest, + MergeProof SurvivorProof, + IReadOnlyList FoldedProofs); + +/// The closed answer to a confirm. Only Confirmed wrote a row. +public abstract record ConfirmOutcome +{ + private ConfirmOutcome() + { + } + + public sealed record Confirmed(Guid MergeId) : ConfirmOutcome; + + /// The plan's block codes (never account names), or merge_in_flight when the database refused an overlapping merge. + public sealed record Blocked(IReadOnlyList Codes) : ConfirmOutcome; + + public sealed record PreviewStale : ConfirmOutcome; + + public sealed record NotParty : ConfirmOutcome; + + public sealed record ProofAccountMismatch : ConfirmOutcome; +} + +/// The closed answer to an undo request. Only Requested wrote. +public abstract record UndoOutcome +{ + private UndoOutcome() + { + } + + public sealed record Requested : UndoOutcome; + + public sealed record NotCommitted : UndoOutcome; + + public sealed record GraceOver : UndoOutcome; + + public sealed record NotSurvivor : UndoOutcome; + + public sealed record ProofAccountMismatch : UndoOutcome; +} + +/// The two ways an operator may end a stuck leg: the forward move is failed, or the undo is given up and the merge stands. +public enum SettleChoice +{ + FailForward, + AbandonUndo +} + +/// The closed answer to an operator's settle. Only Settled wrote. +public abstract record SettleOutcome +{ + private SettleOutcome() + { + } + + public sealed record Settled : SettleOutcome; + + /// The merge is missing, in another state than the choice ends, or not stuck on one of the settleable outcomes. + public sealed record NotStuck : SettleOutcome; + + public sealed record WrongMoveId : SettleOutcome; + + /// The State token went to another writer first; the other transition stands and nothing was changed. + public sealed record Lost : SettleOutcome; +} + +/// +/// The one owner of an account merge's lifecycle outside the ledger's Committed, Final and Undone, and the only caller of +/// . It inserts Confirmed (the consent record, with the Heimdall bearers sealed and the move +/// id minted), runs the one idempotent step function (move, commit, reverse, undo), and writes +/// HeimdallMoved, Failed, UndoRequested and HeimdallReversed. It never writes votes, memberships or journal rows (the ledger's), never +/// judges a proof's tier or freshness (Heimdall's), and reads no standing of its own: the plan's blocks and the database's live-merge +/// index answer overlap, and the writers that act for an account go through . +/// Every transition is one save guarded by State, the merge's concurrency token; the loser of a race reloads and continues from the +/// stored state. Heimdall's closed outcome is consumed as is: Unknown (a lost answer, an outage) is replayed with the same stored +/// move id and never fails a merge. Logs name the merge id, state, outcome class and refusal code only. +/// +public sealed partial class AccountMergeSaga( + BifrostDbContext dbContext, + AccountMergeLedger ledger, + HeimdallIdentityMoveClient mover, + IDataProtectionProvider dataProtection, + AuditTrailService audit, + TimeProvider timeProvider, + ILogger logger) +{ + public const string MergeInFlight = "merge_in_flight"; + public const string ReceiptMismatch = "receipt_mismatch"; + public const string ProofsUnreadable = "proofs_unreadable"; + public const string MoveIdConflict = "move_id_conflict"; + public const string OperatorFailed = "operator_failed"; + public const string UndoOperatorAbandoned = "undo_operator_abandoned"; + + private const string LiveMergeIndex = "UX_AccountMerges_FoldedUserAccountId_Live"; + private const int MaxProofs = 8; + + private static readonly TimeSpan[] Backoff = [TimeSpan.FromSeconds(15), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(15)]; + private static readonly TimeSpan UnreadableRetry = TimeSpan.FromHours(1); + + private static readonly AccountMergeState[] DueStates = + [AccountMergeState.Confirmed, AccountMergeState.HeimdallMoved, AccountMergeState.UndoRequested, AccountMergeState.HeimdallReversed]; + + private readonly IDataProtector _seal = dataProtection.CreateProtector("Bifrost.AccountMerge.Proofs.v1"); + + private sealed record ProofSeal(List From, string? To) + { + // The seal holds live bearers: the generated ToString would print them into any log or exception that holds it. + private bool PrintMembers(StringBuilder builder) + { + builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}"); + return true; + } + } + + // ---- confirm ---- + + /// + /// The member's consent, as one insert. The actor must be one of the pair; the plan must have no blocks and be the one the member + /// saw (digest); every proof must be of its side's Heimdall account. Calls no Heimdall: the caller runs . + /// + public async Task ConfirmAsync(ConfirmMergeRequest request, CancellationToken cancellationToken) + { + RequireProofs(request.SurvivorProof, request.FoldedProofs); + if (request.ActorUserAccountId != request.SurvivorUserAccountId && request.ActorUserAccountId != request.FoldedUserAccountId) + { + return new ConfirmOutcome.NotParty(); + } + + try + { + return await AccountMergeLedger.WriteAsAsync(dbContext, request.ActorUserAccountId, _ => InsertConfirmedAsync(request, cancellationToken), cancellationToken); + } + catch (DbUpdateException error) when (IsLiveMergeViolation(error)) + { + dbContext.ChangeTracker.Clear(); + return new ConfirmOutcome.Blocked([MergeInFlight]); + } + } + + private async Task InsertConfirmedAsync(ConfirmMergeRequest request, CancellationToken cancellationToken) + { + var plan = await ledger.PlanAsync(request.SurvivorUserAccountId, request.FoldedUserAccountId, cancellationToken); + if (plan.Blocks.Count > 0) + { + return new ConfirmOutcome.Blocked(plan.Blocks); + } + + if (!string.Equals(plan.Digest(request.SurvivorUserAccountId, request.FoldedUserAccountId), request.PreviewDigest, StringComparison.Ordinal)) + { + return new ConfirmOutcome.PreviewStale(); + } + + var survivorHeimdall = await HeimdallAccountAsync(request.SurvivorUserAccountId, cancellationToken); + var foldedHeimdall = await HeimdallAccountAsync(request.FoldedUserAccountId, cancellationToken); + if (request.SurvivorProof.HeimdallAccountId != survivorHeimdall || request.FoldedProofs.Any(x => x.HeimdallAccountId != foldedHeimdall)) + { + return new ConfirmOutcome.ProofAccountMismatch(); + } + + var now = timeProvider.GetUtcNow(); + var merge = new AccountMerge + { + State = AccountMergeState.Confirmed, + SurvivorUserAccountId = request.SurvivorUserAccountId, + FoldedUserAccountId = request.FoldedUserAccountId, + CreatedUtc = now, + ConfirmedByUserAccountId = request.ActorUserAccountId, + ConfirmedUtc = now, + PreviewDigest = request.PreviewDigest, + SurvivorProofProvider = request.SurvivorProof.Provider, + FoldedProofProviders = string.Join(',', request.FoldedProofs.Select(x => x.Provider).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)), + // Heimdall's idempotency key: random, minted once here, read back from the row on every call, never derived from the merge id. + HeimdallMoveId = Guid.NewGuid().ToString("N"), + SealedProofs = Seal(request.FoldedProofs.Select(x => x.AccessToken), request.SurvivorProof.AccessToken), + LastOutcome = "confirmed" + }; + dbContext.AccountMerges.Add(merge); + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Account merge {MergeId} {State}", merge.Id, merge.State); + return new ConfirmOutcome.Confirmed(merge.Id); + } + + // ---- undo request ---- + + /// + /// The survivor's request to give the moved logins back, inside the grace and with fresh survivor proofs. One save: UndoRequested, a + /// freshly minted reverse move id, the sealed proofs. Losing the race to the Final sweep is the State token's, answered GraceOver. + /// + public async Task RequestUndoAsync( + Guid mergeId, Guid actorUserAccountId, IReadOnlyList survivorProofs, CancellationToken cancellationToken) + { + RequireProofs(null, survivorProofs); + try + { + return await AccountMergeLedger.WriteAsAsync(dbContext, actorUserAccountId, _ => WriteUndoRequestedAsync(mergeId, actorUserAccountId, survivorProofs, cancellationToken), cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + dbContext.ChangeTracker.Clear(); + return new UndoOutcome.GraceOver(); + } + } + + private async Task WriteUndoRequestedAsync( + Guid mergeId, Guid actorUserAccountId, IReadOnlyList survivorProofs, CancellationToken cancellationToken) + { + var merge = await LoadAsync(mergeId, cancellationToken); + if (merge is null) + { + return new UndoOutcome.NotCommitted(); + } + + var now = timeProvider.GetUtcNow(); + if (actorUserAccountId != merge.SurvivorUserAccountId) + { + return new UndoOutcome.NotSurvivor(); + } + + if (merge.State == AccountMergeState.Final || merge.FinalizedUtc is not null) + { + return new UndoOutcome.GraceOver(); + } + + if (merge.State != AccountMergeState.Committed) + { + return new UndoOutcome.NotCommitted(); + } + + if (merge.GraceEndsUtc is not { } graceEnds || now >= graceEnds) + { + return new UndoOutcome.GraceOver(); + } + + var survivorHeimdall = await HeimdallAccountAsync(merge.SurvivorUserAccountId, cancellationToken); + if (survivorProofs.Any(x => x.HeimdallAccountId != survivorHeimdall)) + { + return new UndoOutcome.ProofAccountMismatch(); + } + + merge.State = AccountMergeState.UndoRequested; + merge.UndoRequestedUtc = now; + merge.HeimdallReverseMoveId = Guid.NewGuid().ToString("N"); + merge.SealedProofs = Seal(survivorProofs.Select(x => x.AccessToken), null); + merge.Attempts = 0; + merge.NextAttemptUtc = null; + merge.LastOutcome = "undo_requested"; + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Account merge {MergeId} {State}", merge.Id, merge.State); + return new UndoOutcome.Requested(); + } + + // ---- the step function ---- + + /// + /// The one step function: confirm's inline advance, the sweeper's resume after a crash and an undo request all run it. From the + /// stored state it makes the Heimdall move (or reversal) under the stored id, records the answer, and calls the ledger's commit (or + /// local undo). Idempotent: every state has one next step and every step is safe to repeat. Other states are a no-op. + /// + public async Task AdvanceAsync(Guid mergeId, CancellationToken cancellationToken) + { + for (var lost = 0; ; lost++) + { + try + { + return await AdvanceOnceAsync(mergeId, cancellationToken); + } + catch (DbUpdateConcurrencyException) when (lost < 2) + { + // Another advancer saved first: forget what this context staged and continue from the stored state. + dbContext.ChangeTracker.Clear(); + } + } + } + + private async Task AdvanceOnceAsync(Guid mergeId, CancellationToken cancellationToken) + { + var merge = await LoadAsync(mergeId, cancellationToken) ?? throw new InvalidOperationException($"Merge {mergeId} does not exist."); + if (merge.State is AccountMergeState.Confirmed or AccountMergeState.UndoRequested) + { + merge = await MoveAsync(merge, cancellationToken); + } + + return merge.State switch + { + AccountMergeState.HeimdallMoved => await ledger.CommitAsync(merge.Id, cancellationToken), + AccountMergeState.HeimdallReversed => await ledger.UndoLocalAsync(merge.Id, cancellationToken), + _ => merge + }; + } + + private async Task MoveAsync(AccountMerge merge, CancellationToken cancellationToken) + { + if (merge.LastOutcome == ReceiptMismatch) + { + return merge; // Heimdall answered, wrongly, and its proofs are wiped: replay can only repeat the same answer. + } + + var forward = merge.State == AccountMergeState.Confirmed; + var now = timeProvider.GetUtcNow(); + if (Open(merge.SealedProofs) is not { } proofs) + { + // The key ring is lost or rotated out. Heimdall may already have moved, so nothing changes but the retry time. + merge.LastOutcome = ProofsUnreadable; + merge.NextAttemptUtc = now + UnreadableRetry; + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogError("Account merge {MergeId} {State}: sealed proofs unreadable", merge.Id, merge.State); + return merge; + } + + var request = forward + ? new HeimdallMoveRequest(merge.HeimdallMoveId!, proofs.From, proofs.To) + : new HeimdallMoveRequest(merge.HeimdallReverseMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId); + var outcome = await mover.MoveAsync(request, cancellationToken); + var code = outcome is HeimdallMoveOutcome.Refused refused ? RefusalCode(refused.Code) : null; + switch (outcome) + { + case HeimdallMoveOutcome.Moved moved: + // The leg's destination is the survivor going forward and the folded account coming back. + var destination = await HeimdallAccountAsync(forward ? merge.SurvivorUserAccountId : merge.FoldedUserAccountId, cancellationToken); + merge.SealedProofs = null; + if (moved.Receipt.AccountId != destination) + { + merge.LastOutcome = ReceiptMismatch; + break; + } + + merge.Attempts = 0; + merge.NextAttemptUtc = null; + merge.LastOutcome = "moved"; + if (forward) + { + merge.State = AccountMergeState.HeimdallMoved; + merge.ProofFromTier = Tier(moved.Receipt.ProofFrom); + merge.ProofToTier = moved.Receipt.ProofTo is { } to ? Tier(to) : null; + merge.MovedIdentityIds = JsonSerializer.Serialize(moved.Receipt.MovedIdentityIds); + } + else + { + merge.State = AccountMergeState.HeimdallReversed; + merge.ReversedUtc = now; + } + + break; + case HeimdallMoveOutcome.Refused: + // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was. + merge.SealedProofs = null; + merge.NextAttemptUtc = null; + if (forward) + { + merge.State = AccountMergeState.Failed; + merge.FailedUtc = now; + merge.FailureCode = code!; + merge.LastOutcome = "refused"; + } + else + { + merge.State = AccountMergeState.Committed; + merge.LastOutcome = "undo_" + code; + } + + break; + case HeimdallMoveOutcome.Unknown unknown: + merge.Attempts++; + merge.NextAttemptUtc = now + Backoff[Math.Min(merge.Attempts, Backoff.Length) - 1]; + merge.LastOutcome = unknown.Reason.Length > 64 ? unknown.Reason[..64] : unknown.Reason; + break; + } + + await dbContext.SaveChangesAsync(cancellationToken); + logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code}", merge.Id, merge.State, outcome.GetType().Name, code); + return merge; + } + + /// Advances every in-flight merge that is due, oldest first. One that throws is logged and skipped; returns how many were advanced. + public async Task AdvanceDueAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + var due = await dbContext.AccountMerges.AsNoTracking() + .Where(x => DueStates.Contains(x.State) && (x.NextAttemptUtc == null || x.NextAttemptUtc <= now)) + .OrderBy(x => x.CreatedUtc) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + var advanced = 0; + foreach (var id in due) + { + try + { + await AdvanceAsync(id, cancellationToken); + advanced++; + } + catch (Exception error) when (error is not OperationCanceledException) + { + dbContext.ChangeTracker.Clear(); + logger.LogWarning("Account merge {MergeId} advance failed: {Error}", id, error.GetType().Name); + } + } + + return advanced; + } + + // ---- operator settle ---- + + /// The Heimdall answers no replay can change: the one the saga recorded and stopped on, the key ring's loss, and a move Heimdall holds but will not confirm. + public static readonly IReadOnlyList SettleableOutcomes = [ReceiptMismatch, ProofsUnreadable, MoveIdConflict]; + + /// + /// Whether an operator may end this merge's leg: in flight (Confirmed or UndoRequested) and stopped on one of the closed + /// outcomes. Derived from the row on every read; there is no stuck flag. + /// + public static bool IsSettleable(AccountMerge merge) => + merge.State is AccountMergeState.Confirmed or AccountMergeState.UndoRequested && SettleableOutcomes.Contains(merge.LastOutcome); + + /// + /// A platform admin's end of a stuck leg (ruling merge-stuck-end-only-typed-id): FailForward turns a stuck Confirmed merge into + /// Failed, AbandonUndo returns a stuck UndoRequested merge to Committed with its grace. Only with the leg's stored move id typed back. + /// Never adopts or invents a Heimdall receipt; a merge Heimdall moved (HeimdallMoved, Committed) is never settleable. One save + /// guarded by State carries the transition and its audit row (choice only): the loser of a race with + /// answers Lost and changes nothing. The caller has checked the operator's authority. + /// + public async Task SettleStuckAsync( + Guid mergeId, SettleChoice choice, string typedMoveId, Guid operatorUserAccountId, CancellationToken cancellationToken) + { + var forward = choice == SettleChoice.FailForward; + var merge = await LoadAsync(mergeId, cancellationToken); + if (merge is null || merge.State != (forward ? AccountMergeState.Confirmed : AccountMergeState.UndoRequested) || !IsSettleable(merge)) + { + return new SettleOutcome.NotStuck(); + } + + if (!string.Equals(typedMoveId, forward ? merge.HeimdallMoveId : merge.HeimdallReverseMoveId, StringComparison.Ordinal)) + { + return new SettleOutcome.WrongMoveId(); + } + + merge.SealedProofs = null; + merge.NextAttemptUtc = null; + if (forward) + { + merge.State = AccountMergeState.Failed; + merge.FailedUtc = timeProvider.GetUtcNow(); + merge.FailureCode = OperatorFailed; + merge.LastOutcome = OperatorFailed; + } + else + { + merge.State = AccountMergeState.Committed; + merge.LastOutcome = UndoOperatorAbandoned; + } + + try + { + await audit.RecordAsync(operatorUserAccountId, nameof(AccountMerge), merge.Id, "merge.settled", choice.ToString(), cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + dbContext.ChangeTracker.Clear(); + return new SettleOutcome.Lost(); + } + + logger.LogInformation("Account merge {MergeId} {State}: settled by an operator ({Choice})", merge.Id, merge.State, choice); + return new SettleOutcome.Settled(); + } + + // ---- helpers ---- + + /// The merge as stored now: a row this context already tracks is reloaded, never trusted. + private async Task LoadAsync(Guid mergeId, CancellationToken cancellationToken) + { + var tracked = dbContext.AccountMerges.Local.FirstOrDefault(x => x.Id == mergeId); + if (tracked is not null) + { + await dbContext.Entry(tracked).ReloadAsync(cancellationToken); + return dbContext.Entry(tracked).State == EntityState.Detached ? null : tracked; + } + + return await dbContext.AccountMerges.SingleOrDefaultAsync(x => x.Id == mergeId, cancellationToken); + } + + private Task HeimdallAccountAsync(Guid userAccountId, CancellationToken cancellationToken) => + dbContext.UserAccounts.AsNoTracking().Where(x => x.Id == userAccountId).Select(x => x.HeimdallAccountId).SingleAsync(cancellationToken); + + /// Shapes Heimdall would 400 are refused before a row exists, so a confirm can never store a merge that cannot call. Messages name no value. + private static void RequireProofs(MergeProof? one, IReadOnlyList many) + { + if (many is not { Count: >= 1 and <= MaxProofs }) + { + throw new ArgumentException("1 to 8 proofs are required", nameof(many)); + } + + foreach (var proof in one is null ? many : [one, .. many]) + { + if (string.IsNullOrEmpty(proof.AccessToken) || proof.AccessToken.Length > HeimdallIdentityMoveClient.MaxProofLength + || string.IsNullOrEmpty(proof.HeimdallAccountId) || string.IsNullOrEmpty(proof.Provider) || proof.Provider.Length > 32) + { + throw new ArgumentException("a proof is malformed", nameof(many)); + } + } + + if (many.Select(x => x.Provider).Distinct(StringComparer.Ordinal).Sum(x => x.Length + 1) > 121) + { + throw new ArgumentException("the proofs' providers do not fit the record", nameof(many)); + } + } + + private string Seal(IEnumerable from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to))); + + private ProofSeal? Open(string? sealedProofs) + { + if (sealedProofs is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(_seal.Unprotect(sealedProofs)) is { From.Count: > 0 } proofs ? proofs : null; + } + catch (Exception error) when (error is CryptographicException or JsonException) + { + return null; + } + } + + private static string Tier(HeimdallProofTier tier) => tier == HeimdallProofTier.Strong ? "strong" : "weak"; + + private static string RefusalCode(HeimdallMoveRefusal code) => + code == HeimdallMoveRefusal.RequestInvalid ? "invalid_request" : WordBoundary().Replace(code.ToString(), "_$1").ToLowerInvariant(); + + [GeneratedRegex("(?The database's refusal of an overlapping merge: the live-merge unique index or the chain trigger's exclusion violation. + private static bool IsLiveMergeViolation(DbUpdateException error) + { + for (var current = (Exception?)error; current is not null; current = current.InnerException) + { + if (current is PostgresException { SqlState: "23P01" } or PostgresException { SqlState: "23505", ConstraintName: LiveMergeIndex }) + { + return true; + } + } + + return false; + } +} diff --git a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs index cb5626c..06e5622 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. /// @@ -49,6 +49,12 @@ public sealed class MembershipSuspensionService(BifrostDbContext dbContext, Audi return SuspensionOutcome.NoSuchAccount; } + // A merged account is terminal: only the merge ledger leaves that status, so neither suspending nor lifting applies. + if (membership.IsMerged) + { + return SuspensionOutcome.NotApplicable; + } + // An admin cannot suspend themselves or another platform admin here (the same derivation that makes an actor an admin): // bootstrap admins are re-promoted to Active at sign-in by configuration, so the suspension would be undone without a receipt. if (suspend && (user.Id == actor.UserAccount.Id || new CurrentBifrostActor(true, user).IsPlatformAdmin)) @@ -65,14 +71,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/Membership/MergeNoticeEndpoints.cs b/src/Bifrost.Web/Features/Membership/MergeNoticeEndpoints.cs new file mode 100644 index 0000000..909d8b9 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MergeNoticeEndpoints.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Domain; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Membership; + +/// +/// The chat host's pull of merge notices: GET the unacked feed, POST an ack. JSON only because the reader is the chat host's script (a xenos +/// boundary). The bearer is the chat host's own secret (ChatNotices:Token); an empty token turns both routes off. No cookie, no CORS, never stored. +/// +public static class MergeNoticeEndpoints +{ + private const int MaxAckBodyBytes = 1024; + + public static void Map(WebApplication app) + { + app.MapGet("/api/merge-notices/chat", async (HttpContext http, IOptions options, MergeNoticeFeed feed, CancellationToken ct) => + { + if (Gate(http, options.Value) is { } refused) + { + return refused; + } + + DateTimeOffset? after = null; + if (http.Request.Query.TryGetValue("after", out var raw) && raw.Count > 0) + { + if (!DateTimeOffset.TryParse(raw[0], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)) + { + return Results.StatusCode(StatusCodes.Status400BadRequest); + } + + after = parsed; + } + + int? limit = http.Request.Query.TryGetValue("limit", out var rawLimit) && int.TryParse(rawLimit[0], CultureInfo.InvariantCulture, out var n) ? n : null; + var notices = await feed.ReadAsync(AccountMergeNoticeTarget.Chat, after, limit, ct); + return Results.Text(JsonSerializer.Serialize(notices.Select(Wire)), "application/json"); + }).AllowAnonymous(); + + app.MapPost("/api/merge-notices/chat/ack", async (HttpContext http, IOptions options, MergeNoticeFeed feed, CancellationToken ct) => + { + if (Gate(http, options.Value) is { } refused) + { + return refused; + } + + if (await ReadAckAsync(http.Request, ct) is not var (mergeId, kind)) + { + return Results.StatusCode(StatusCodes.Status400BadRequest); + } + + return await feed.AckAsync(AccountMergeNoticeTarget.Chat, mergeId, kind, ct) + ? Results.StatusCode(StatusCodes.Status204NoContent) + : Results.StatusCode(StatusCodes.Status404NotFound); + }).AllowAnonymous(); + } + + private static IResult? Gate(HttpContext http, ChatNoticesOptions options) + { + http.Response.Headers.CacheControl = "no-store"; + if (string.IsNullOrEmpty(options.Token)) + { + return Results.StatusCode(StatusCodes.Status404NotFound); + } + + var header = http.Request.Headers.Authorization.ToString(); + const string scheme = "Bearer "; + var presented = header.StartsWith(scheme, StringComparison.Ordinal) ? header[scheme.Length..] : string.Empty; + // Both sides are hashed first, so the comparison is over equal-length digests and says nothing about the token's length. + var matches = CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(presented)), SHA256.HashData(Encoding.UTF8.GetBytes(options.Token))); + return matches && presented.Length > 0 ? null : Results.StatusCode(StatusCodes.Status401Unauthorized); + } + + private static async Task<(Guid MergeId, AccountMergeNoticeKind Kind)?> ReadAckAsync(HttpRequest request, CancellationToken ct) + { + var buffer = new byte[MaxAckBodyBytes + 1]; + var read = 0; + int n; + while (read < buffer.Length && (n = await request.Body.ReadAsync(buffer.AsMemory(read), ct)) > 0) + { + read += n; + } + + if (read == 0 || read > MaxAckBodyBytes) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(buffer.AsMemory(0, read)); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("merge_id", out var id) || id.ValueKind != JsonValueKind.String || !Guid.TryParse(id.GetString(), out var mergeId) || + !root.TryGetProperty("type", out var type) || type.ValueKind != JsonValueKind.String) + { + return null; + } + + AccountMergeNoticeKind? kind = type.GetString() switch + { + "merged" => AccountMergeNoticeKind.Merged, + "undone" => AccountMergeNoticeKind.Undone, + "final" => AccountMergeNoticeKind.Final, + _ => null + }; + return kind is { } k ? (mergeId, k) : null; + } + catch (JsonException) + { + return null; + } + } + + /// The wire shape, exactly these keys: ids in format D, times ISO 8601 UTC, no email, display name, Heimdall id or Matrix id. + private static Dictionary Wire(MergeNotice notice) => new() + { + ["type"] = notice.Kind.ToString().ToLowerInvariant(), + ["merge_id"] = notice.MergeId.ToString("D"), + ["occurred_at"] = notice.OccurredAt.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ["grace_ends_at"] = notice.GraceEndsAt?.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + ["old_subject"] = notice.OldSubject.ToString("D"), + ["new_subject"] = notice.NewSubject.ToString("D"), + ["old_preferred_username"] = notice.OldPreferredUsername, + ["new_preferred_username"] = notice.NewPreferredUsername + }; +} diff --git a/src/Bifrost.Web/Features/Membership/MergeNoticeFeed.cs b/src/Bifrost.Web/Features/Membership/MergeNoticeFeed.cs new file mode 100644 index 0000000..bc0fe05 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MergeNoticeFeed.cs @@ -0,0 +1,96 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Membership; + +/// What the chat host is told about one merge. Derived from the merge row on every read; never stored. +public sealed record MergeNotice( + AccountMergeNoticeKind Kind, + Guid MergeId, + DateTimeOffset OccurredAt, + DateTimeOffset? GraceEndsAt, + Guid OldSubject, + Guid NewSubject, + string OldPreferredUsername, + string NewPreferredUsername); + +/// +/// The one owner of what a notice reader is told about merges: merged (CommittedUtc), undone (UndoneUtc) and final (FinalizedUtc) are read off +/// the AccountMerges row on every call, so a notice cannot disagree with the merge, and the reader's ack row is the only thing stored. +/// Reads only AccountMerges and UserAccounts; writes only AccountMergeNoticeAck. +/// +public sealed class MergeNoticeFeed(BifrostDbContext dbContext, TimeProvider timeProvider) +{ + public const int DefaultLimit = 50; + public const int MaxLimit = 200; + + private static readonly AccountMergeNoticeKind[] Kinds = [AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Undone, AccountMergeNoticeKind.Final]; + + private static DateTimeOffset? OccurredAt(AccountMerge merge, AccountMergeNoticeKind kind) => kind switch + { + AccountMergeNoticeKind.Merged => merge.CommittedUtc, + AccountMergeNoticeKind.Undone => merge.UndoneUtc, + _ => merge.FinalizedUtc + }; + + /// Unacked notices for , oldest first by (occurred_at, merge_id, kind), strictly later than when given. Acks are the cursor; after only narrows the scan. + public async Task> ReadAsync(AccountMergeNoticeTarget target, DateTimeOffset? after, int? limit, CancellationToken cancellationToken) + { + var take = limit is > 0 ? Math.Min(limit.Value, MaxLimit) : DefaultLimit; + var picked = new List<(AccountMerge Merge, AccountMergeNoticeKind Kind, DateTimeOffset At)>(); + // Each kind's own oldest `take` rows contain every row of the overall oldest `take`, so merging three bounded lists is exact. + foreach (var kind in Kinds) + { + var rows = await dbContext.AccountMerges.AsNoTracking() + .Where(x => (kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) != null) + .Where(x => after == null || (kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) > after) + .Where(x => !dbContext.AccountMergeNoticeAcks.Any(a => a.MergeId == x.Id && a.Kind == kind && a.Target == target)) + .OrderBy(x => kind == AccountMergeNoticeKind.Merged ? x.CommittedUtc : kind == AccountMergeNoticeKind.Undone ? x.UndoneUtc : x.FinalizedUtc) + .ThenBy(x => x.Id) + .Take(take) + .ToListAsync(cancellationToken); + picked.AddRange(rows.Select(x => (x, kind, OccurredAt(x, kind)!.Value))); + } + + var page = picked.OrderBy(x => x.At).ThenBy(x => x.Merge.Id).ThenBy(x => x.Kind).Take(take).ToList(); + var ids = page.SelectMany(x => new[] { x.Merge.FoldedUserAccountId, x.Merge.SurvivorUserAccountId }).Distinct().ToList(); + var names = await dbContext.UserAccounts.AsNoTracking().Where(x => ids.Contains(x.Id)) + .Select(x => new { x.Id, x.NormalizedBifrostIdentity }).ToDictionaryAsync(x => x.Id, x => x.NormalizedBifrostIdentity, cancellationToken); + return [.. page.Select(x => new MergeNotice( + x.Kind, x.Merge.Id, x.At, x.Merge.GraceEndsUtc, x.Merge.FoldedUserAccountId, x.Merge.SurvivorUserAccountId, + names.GetValueOrDefault(x.Merge.FoldedUserAccountId, string.Empty), names.GetValueOrDefault(x.Merge.SurvivorUserAccountId, string.Empty)))]; + } + + /// Records that the reader has seen (merge, kind). False when the feed would not emit that notice now (no such merge, or its timestamp unset); true otherwise, a repeat included. + public async Task AckAsync(AccountMergeNoticeTarget target, Guid mergeId, AccountMergeNoticeKind kind, CancellationToken cancellationToken) + { + var merge = await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == mergeId, cancellationToken); + if (merge is null || OccurredAt(merge, kind) is null) + { + return false; + } + + if (await dbContext.AccountMergeNoticeAcks.AnyAsync(x => x.MergeId == mergeId && x.Kind == kind && x.Target == target, cancellationToken)) + { + return true; + } + + dbContext.AccountMergeNoticeAcks.Add(new AccountMergeNoticeAck { MergeId = mergeId, Kind = kind, Target = target, AckedUtc = timeProvider.GetUtcNow() }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // A concurrent ack of the same key won the unique index; the notice is acked, which is what the caller asked for. + dbContext.ChangeTracker.Clear(); + if (!await dbContext.AccountMergeNoticeAcks.AnyAsync(x => x.MergeId == mergeId && x.Kind == kind && x.Target == target, cancellationToken)) + { + throw; + } + } + + return true; + } +} diff --git a/src/Bifrost.Web/Features/Membership/MergePreview.cs b/src/Bifrost.Web/Features/Membership/MergePreview.cs new file mode 100644 index 0000000..d241a68 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MergePreview.cs @@ -0,0 +1,93 @@ +using Bifrost.Web.Domain; + +namespace Bifrost.Web.Features.Membership; + +/// +/// The only wording of what a merge would do, in closed vocabulary: counts by entry kind and action from , the +/// sentence for each block code, and the statements every merge carries. It decides nothing, and names no account: the plan holds none. +/// +public static class MergePreview +{ + public static IReadOnlyList Lines(MergePlan plan) + { + var lines = new List(); + foreach (var group in plan.Entries.GroupBy(x => (x.Kind, x.Action))) + { + var n = group.Count(); + lines.Add(group.Key switch + { + (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved) => + $"{Count(n, "open vote")} of the other account on motions you did not vote on move to the kept account.", + (AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided) => + $"{Count(n, "open vote")} of the other account are set aside: the kept account already voted on that motion or cannot vote.", + (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Moved) => + $"{Count(n, "appraisal")} of the other account move to the kept account.", + (AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided) => + $"{Count(n, "appraisal")} of the other account are set aside: the kept account already appraised the same thing or cannot.", + (AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed) => + "The other account's membership closes; the kept account's membership stays.", + (AccountMergeEntryKind.Standing, _) => + "The kept account is suspended too, because the other account is.", + (AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved) => + $"{Count(n, "patron event")} of the other account count for the kept account.", + _ => $"{Count(n, "other record")} change." + }); + } + + foreach (var (key, n) in plan.Left.OrderBy(x => x.Key, StringComparer.Ordinal)) + { + if (n > 0 && Remains(key) is { } noun) + { + lines.Add($"{Count(n, noun)} stay{(n == 1 ? "s" : string.Empty)} where {(n == 1 ? "it is" : "they are")}."); + } + } + + if (lines.Count == 0) + { + lines.Add("No votes, appraisals or patron events change."); + } + + return lines; + } + + /// Why a pair cannot be merged, by the plan's block code. No account is named; a code this class does not know gets a generic line, never an echo. + public static string Block(string code) => code switch + { + "same_account" => "These are the same account.", + "account_missing" => "One of the accounts no longer exists.", + "mind_account" => "An account that belongs to a Mind cannot be merged.", + "native_account" => "An account without a Heimdall sign-in cannot be merged.", + "merge_in_flight" => "One of the accounts is already part of a merge that has not finished.", + "survivor_is_folded" => "The account you would keep was already merged into another account.", + "already_folded" => "The other account was already merged into another account.", + "folded_is_survivor" => "Another account was merged into the other account, so it cannot be folded in turn.", + "sealed_ballot_pending" => "The other account has a sealed ballot on an open motion; wait until it is revealed.", + _ => "These accounts cannot be merged now." + }; + + /// The block for two accounts that both hold a login of one provider, which Heimdall will not combine. Providers only: no account is named. + public static string ProviderConflict(IEnumerable providerNames) => + $"Both accounts hold a {string.Join(" and ", providerNames)} login, and Heimdall will not combine two logins of one provider. Unlink one of them first, then prove that account again: the sign-in you gave still carries the old list of its logins."; + + /// What every merge says, whatever the plan: what it cannot carry over, what it asks again, and how long it can be undone. + public static IReadOnlyList Statements { get; } = + [ + "Chat: your chat history, rooms and keys belong to one chat id and cannot be moved. Keep the account you use in chat. Whichever you keep, the other chat id stays behind: rooms you were in on it must be joined again with the kept one, and messages encrypted for it cannot be read on the kept one.", + "Your connections (Twitch, Discord and others) will ask for consent again.", + "If you stream or moderate with StreamPixels and one of these connections is the one it uses for your channel, that connection stops until you reconnect it.", + "Roles and admin rights of the other account end; a steward must grant them again.", + "You will have one membership and one vote per motion: where both accounts voted on an open motion, the kept account's vote counts and the other is set aside. Nothing is added together.", + "You can undo this for 7 days, from Merge status. After that it is final.", + "The merge is approved with ordinary sign-ins, which is the weaker proof, and a notice stays on your account until the 7 days end." + ]; + + private static string Count(int n, string noun) => n == 1 ? $"1 {noun}" : $"{n} {noun}s"; + + private static string? Remains(string key) => key switch + { + "ConsentReceipts" => "consent record", + "PasskeyCredentials" => "passkey", + "LedgerEntries" => "ledger entry", + _ => null + }; +} diff --git a/src/Bifrost.Web/Features/Membership/MergeProofs.cs b/src/Bifrost.Web/Features/Membership/MergeProofs.cs new file mode 100644 index 0000000..86b45f4 --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MergeProofs.cs @@ -0,0 +1,238 @@ +using Bifrost.Web.Features.Heimdall; + +namespace Bifrost.Web.Features.Membership; + +public enum MergeSide +{ + /// The account signed in here. + Self, + + /// The second account, proved by its own fresh sign-in. + Other +} + +public enum PutOutcome +{ + Stored, + AmbiguousLogins, + TooManyProofs +} + +/// What a page may know of one side: no bearer, no Heimdall account id, no login names. +public sealed record MergeSideView( + Guid UserAccountId, IReadOnlyList ProvedProviders, IReadOnlyList LoginProviders, TimeSpan OldestProofAge, DateTimeOffset ExpiresAt); + +public sealed record MergeProofsView(MergeSideView? Self, MergeSideView? Other); + +public abstract record PeekResult +{ + private PeekResult() + { + } + + public sealed record Ready(MergeProof Survivor, IReadOnlyList Folded) : PeekResult; + + /// A side holds no live proof. + public sealed record Expired : PeekResult; + + /// Logins whose provider has no live proof, by provider name only. + public sealed record LoginsUnproved(IReadOnlyList Providers) : PeekResult; + + /// Both accounts hold a login of these providers, a pair Heimdall always refuses; by provider name only. + public sealed record ProviderConflict(IReadOnlyList Providers) : PeekResult; +} + +public abstract record UndoPeekResult +{ + private UndoPeekResult() + { + } + + public sealed record Ready(IReadOnlyList Proofs) : UndoPeekResult; + + public sealed record Expired : UndoPeekResult; + + public sealed record LoginsUnproved(IReadOnlyList Providers) : UndoPeekResult; +} + +/// +/// The one holder of live Heimdall bearers between a merge-proof sign-in's completion and the page's call into the saga, keyed by the +/// acting Bifrost account. It lives in this process's memory only and is never persisted, logged or sent to a browser; after a restart +/// the member proves again. It decides nothing about the merge: the saga owns consent, the move and the lifecycle, and Heimdall judges +/// each proof's tier and freshness. is only a margin under Heimdall's 300 s weak window, so a stale proof +/// is not sent to fail a merge. Provider is the only proof bookkeeping Bifrost has (the redeem names the provider, not the identity), +/// so a login list holding two logins of one provider is refused, and two sides holding a login of one provider are a conflict Peek names before consent. Every access, and the merge sweeper's tick, drops expired bearers. Peek never consumes: the caller s once the saga +/// has taken the proofs, so a refusal that wrote nothing does not cost a re-proof. +/// +public sealed class MergeProofs(TimeProvider timeProvider) +{ + public static readonly TimeSpan ProofLifetime = TimeSpan.FromSeconds(270); + + /// The saga's own bound on the proofs of one move. + public const int MaxProofsPerSide = 8; + + private sealed record Held(MergeProof Proof, DateTimeOffset RedeemedAt); + + private sealed class Side(Guid userAccountId, string heimdallAccountId) + { + public Guid UserAccountId { get; } = userAccountId; + + public string HeimdallAccountId { get; } = heimdallAccountId; + + public IReadOnlyList Logins { get; set; } = []; + + public Dictionary Proofs { get; } = new(StringComparer.Ordinal); + } + + private readonly Dictionary> _held = []; + private readonly object _gate = new(); + + public PutOutcome Put(Guid actorId, MergeSide side, MergeProof proof, Guid userAccountId, IReadOnlyList logins) + { + if (logins.GroupBy(x => x.Provider, StringComparer.Ordinal).Any(x => x.Count() > 1)) + { + return PutOutcome.AmbiguousLogins; + } + + lock (_gate) + { + var now = timeProvider.GetUtcNow(); + Prune(now); + var sides = _held.TryGetValue(actorId, out var existing) ? existing : _held[actorId] = []; + if (!sides.TryGetValue(side, out var held) || held.UserAccountId != userAccountId || held.HeimdallAccountId != proof.HeimdallAccountId) + { + held = new Side(userAccountId, proof.HeimdallAccountId); + } + + if (!held.Proofs.ContainsKey(proof.Provider) && held.Proofs.Count >= MaxProofsPerSide) + { + return PutOutcome.TooManyProofs; + } + + held.Logins = logins; + held.Proofs[proof.Provider] = new Held(proof, now); + sides[side] = held; + return PutOutcome.Stored; + } + } + + public MergeProofsView Snapshot(Guid actorId) + { + lock (_gate) + { + var now = timeProvider.GetUtcNow(); + Prune(now); + var sides = _held.GetValueOrDefault(actorId); + return new MergeProofsView(View(sides, MergeSide.Self, now), View(sides, MergeSide.Other, now)); + } + } + + public PeekResult Peek(Guid actorId, MergeSide survivorSide) + { + lock (_gate) + { + Prune(timeProvider.GetUtcNow()); + var sides = _held.GetValueOrDefault(actorId); + if (sides is null || !sides.TryGetValue(survivorSide, out var survivor) + || !sides.TryGetValue(survivorSide == MergeSide.Self ? MergeSide.Other : MergeSide.Self, out var folded)) + { + return new PeekResult.Expired(); + } + + var shared = survivor.Logins.Select(x => x.Provider).Intersect(folded.Logins.Select(x => x.Provider), StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + if (shared.Length > 0) + { + return new PeekResult.ProviderConflict(shared); + } + + var unproved = UnprovedProviders(folded, folded.Logins.Select(x => x.Provider)); + return unproved.Count > 0 + ? new PeekResult.LoginsUnproved(unproved) + : new PeekResult.Ready(survivor.Proofs.Values.MaxBy(x => x.RedeemedAt)!.Proof, [.. folded.Proofs.Values.Select(x => x.Proof)]); + } + } + + /// The Self side's proofs for an undo, once every login whose id is in is proved. + public UndoPeekResult PeekUndo(Guid actorId, IReadOnlyCollection movedIdentityIds) + { + lock (_gate) + { + Prune(timeProvider.GetUtcNow()); + if (!_held.TryGetValue(actorId, out var sides) || !sides.TryGetValue(MergeSide.Self, out var self)) + { + return new UndoPeekResult.Expired(); + } + + var needed = self.Logins.Where(x => movedIdentityIds.Contains(x.Id)).Select(x => x.Provider).ToArray(); + var unproved = UnprovedProviders(self, needed); + if (needed.Length < movedIdentityIds.Count) + { + // A moved login the list does not hold cannot be proved by it. + return new UndoPeekResult.LoginsUnproved(unproved); + } + + return unproved.Count > 0 + ? new UndoPeekResult.LoginsUnproved(unproved) + : new UndoPeekResult.Ready([.. needed.Distinct(StringComparer.Ordinal).Select(x => self.Proofs[x].Proof)]); + } + } + + public void Clear(Guid actorId) + { + lock (_gate) + { + Prune(timeProvider.GetUtcNow()); + _held.Remove(actorId); + } + } + + /// Drops every expired bearer without any member touching the page; the merge sweeper's tick calls it, so none outlives plus one tick. + public void PruneExpired() + { + lock (_gate) + { + Prune(timeProvider.GetUtcNow()); + } + } + + private static List UnprovedProviders(Side side, IEnumerable providers) => + [.. providers.Distinct(StringComparer.Ordinal).Where(x => !side.Proofs.ContainsKey(x))]; + + private MergeSideView? View(Dictionary? sides, MergeSide which, DateTimeOffset now) => + sides is not null && sides.TryGetValue(which, out var side) + ? new MergeSideView( + side.UserAccountId, + [.. side.Proofs.Keys], + [.. side.Logins.Select(x => x.Provider)], + now - side.Proofs.Values.Min(x => x.RedeemedAt), + side.Proofs.Values.Min(x => x.RedeemedAt) + ProofLifetime) + : null; + + /// Drops every proof at or past its lifetime, then every side without one, then every actor without a side. + private void Prune(DateTimeOffset now) + { + foreach (var (actor, sides) in _held.ToArray()) + { + foreach (var (which, side) in sides.ToArray()) + { + foreach (var (provider, held) in side.Proofs.ToArray()) + { + if (now - held.RedeemedAt >= ProofLifetime) + { + side.Proofs.Remove(provider); + } + } + + if (side.Proofs.Count == 0) + { + sides.Remove(which); + } + } + + if (sides.Count == 0) + { + _held.Remove(actor); + } + } + } +} diff --git a/src/Bifrost.Web/Features/Membership/MergeWording.cs b/src/Bifrost.Web/Features/Membership/MergeWording.cs new file mode 100644 index 0000000..9d6984d --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MergeWording.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using Bifrost.Web.Domain; + +namespace Bifrost.Web.Features.Membership; + +/// +/// The one closed set of words the merge pages say, so no page ever prints what the store holds: a state, a Heimdall refusal code, a +/// saga outcome and an undo answer each map to a sentence here, and anything this class does not know gets a generic line, never an +/// echo. Dates are UTC. +/// +public static class MergeWording +{ + public static string State(AccountMergeState state) => state switch + { + AccountMergeState.Proposed => "Proposed, not yet confirmed.", + AccountMergeState.Confirmed => "Confirmed; Heimdall has not yet moved the logins.", + AccountMergeState.HeimdallMoved => "The logins have moved; Bifrost is finishing the merge.", + AccountMergeState.Committed => "Merged. You can still undo it.", + AccountMergeState.Final => "Merged for good; the undo window has ended.", + AccountMergeState.UndoRequested => "Undo requested; the logins are being given back.", + AccountMergeState.HeimdallReversed => "The logins are back; Bifrost is restoring the accounts.", + AccountMergeState.Undone => "Undone; the accounts are separate again.", + AccountMergeState.Failed => "The merge did not happen and nothing was merged.", + _ => "In an unknown state." + }; + + /// Why Heimdall refused a merge, by its refusal code; null for a code this class does not know. + public static string? Failure(string code) => code switch + { + "app_auth_required" or "not_allowed_for_app" => "Heimdall would not take Bifrost's request.", + "not_reversible" => "Heimdall says those logins cannot be moved back.", + "proof_invalid" => "Heimdall did not accept a sign-in as proof.", + "same_account" => "Both sides were the same Heimdall account.", + "proof_stale" => "A sign-in was too old by the time Heimdall saw it.", + "identity_not_proved" => "A login was not proved by a fresh sign-in.", + "already_moved" => "Heimdall had already moved one of those logins.", + "provider_conflict" => "Both accounts hold a login of the same provider, which Heimdall will not combine.", + "identity_not_found" => "A login was no longer on its account.", + "invalid_request" => "Heimdall could not read the request.", + _ => null + }; + + /// The saga's last note on a merge, in words. Always a sentence: an outcome this class does not know is a retry line, never its own text. + public static string Outcome(string lastOutcome) => lastOutcome switch + { + "confirmed" => "Waiting for Heimdall to move the logins.", + "moved" => "Heimdall moved the logins.", + "refused" => "Heimdall refused the merge.", + "undo_requested" => "Waiting for Heimdall to give the logins back.", + "operator_failed" => "A steward ended this merge. Nothing was merged.", + "undo_operator_abandoned" => "A steward ended the undo, so the merge stands.", + "receipt_mismatch" => "Heimdall's answer did not match the merge. An operator must look at it.", + "proofs_unreadable" => "The stored proofs cannot be read. An operator must look at it.", + "transport" or "move_id_conflict" or "malformed or mismatched receipt" => "Heimdall could not be reached or gave an answer Bifrost could not use. It will try again.", + _ when lastOutcome.StartsWith("undo_", StringComparison.Ordinal) && Failure(lastOutcome["undo_".Length..]) is { } reason => + "Heimdall refused the undo, so the merge stands. " + reason, + _ => "Waiting to try again." + }; + + /// The words for a closed undo code ( or logins_unproved); empty for anything else. + public static string Undo(string code) => code switch + { + "requested" => "Undo requested. The logins are being given back.", + "not_committed" => "This merge is not in a state that can be undone.", + "grace_over" => "The undo window has ended.", + "not_survivor" => "Only the account the merge kept can undo it.", + "proof_account_mismatch" => "That sign-in was not for this account. Nothing was changed.", + "logins_unproved" => "Every login that moved needs a fresh sign-in first. Nothing was changed.", + _ => string.Empty + }; + + /// The words for a closed code the merge page answers a confirm or a start with; empty for anything else. + public static string Confirm(string code) => code switch + { + "blocked" => "These accounts cannot be merged now. Nothing was changed.", + "preview_stale" => "Something changed since you looked; review the merge again. Nothing was changed and your sign-ins are kept.", + "not_party" => "Only one of the two accounts can confirm this merge. Nothing was changed.", + "proof_account_mismatch" => "A sign-in was not for the account it was meant for. Nothing was changed.", + "proofs_needed" => "Both accounts need a fresh sign-in, one for every login of the account that would be folded. Nothing was changed.", + "consent_needed" => "Tick both boxes to confirm. Nothing was changed.", + "bad_choice" => "Choose which account to keep. Nothing was changed.", + _ => string.Empty + }; + + public static string UndoCode(UndoOutcome outcome) => outcome switch + { + UndoOutcome.Requested => "requested", + UndoOutcome.NotCommitted => "not_committed", + UndoOutcome.GraceOver => "grace_over", + UndoOutcome.NotSurvivor => "not_survivor", + UndoOutcome.ProofAccountMismatch => "proof_account_mismatch", + _ => "not_committed" + }; + + public static string TierNote(string? fromTier, DateTimeOffset graceEnds) => fromTier == "strong" + ? $"Approved with a passkey; you have until {Date(graceEnds)} to undo." + : $"Approved with ordinary sign-ins; you have until {Date(graceEnds)} to undo."; + + /// Whole days from to , rounded up, at least one while the window is open. + public static int DaysUntil(DateTimeOffset end, DateTimeOffset now) => Math.Max(1, (int)Math.Ceiling((end - now).TotalDays)); + + public static string DaysLeft(int days) => days == 1 ? "1 day left" : $"{days} days left"; + + public static string SurvivorNotice(int days) => $"A merge into your account can still be undone: {DaysLeft(days)}. Review it on Merge status."; + + public static string UndoRequestedNotice() => "A merge into your account is being undone: Heimdall is giving the logins back. Review it on Merge status."; + + public static string FrozenNotice() => "A merge is in progress; you cannot act until it settles."; + + public static string FoldedNotice(DateTimeOffset mergedOn) => + $"This account was merged on {Date(mergedOn)}; sign in with a login you had on it to reach the account that holds them."; + + public static string Date(DateTimeOffset when) => when.UtcDateTime.ToString("d MMM yyyy", CultureInfo.InvariantCulture) + " UTC"; +} diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index bbb1d6b..ba60a2f 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; @@ -52,6 +53,7 @@ public sealed class MotionGovernanceService( CancellationToken cancellationToken) { var actorUser = actor.UserAccount ?? throw new UnauthorizedAccessException("A linked Bifrost actor is required."); + var motion = await dbContext.Motions .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); @@ -61,28 +63,44 @@ public sealed class MotionGovernanceService( return; } - var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); - if (vote is null) + // An actor resolved before a merge froze this account must not write a vote after it: the write gate reads standing at the write. + var wrote = await AccountMergeLedger.WriteAsAsync(dbContext, actorUser.Id, async standing => { - dbContext.Votes.Add(new Vote + if (standing is not null) { - MotionId = motionId, - UserAccountId = actorUser.Id, - Choice = choice, - Weight = actor.EffectiveVotingWeight, - Comment = comment?.Trim() ?? string.Empty, - CastAtUtc = timeProvider.GetUtcNow() - }); - } - else + return false; + } + + var vote = motion.Votes.SingleOrDefault(x => x.UserAccountId == actorUser.Id); + if (vote is null) + { + dbContext.Votes.Add(new Vote + { + MotionId = motionId, + UserAccountId = actorUser.Id, + Choice = choice, + Weight = actor.EffectiveVotingWeight, + Comment = comment?.Trim() ?? string.Empty, + CastAtUtc = timeProvider.GetUtcNow() + }); + } + else + { + vote.Choice = choice; + vote.Weight = actor.EffectiveVotingWeight; + vote.Comment = comment?.Trim() ?? string.Empty; + vote.CastAtUtc = timeProvider.GetUtcNow(); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return true; + }, cancellationToken); + + if (!wrote) { - vote.Choice = choice; - vote.Weight = actor.EffectiveVotingWeight; - vote.Comment = comment?.Trim() ?? string.Empty; - vote.CastAtUtc = timeProvider.GetUtcNow(); + return; } - await dbContext.SaveChangesAsync(cancellationToken); await auditTrailService.RecordAsync( actorUser.Id, nameof(Motion), diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index 818465e..7fc3af0 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.RegularExpressions; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -99,21 +100,27 @@ public sealed class SealedBallotService( throw new SealedBallotException(SealedBallotFailure.Invalid, "A commitment is 64 lowercase hex characters (SHA-256)."); } - var ballot = await dbContext.SealedBallots - .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); - if (ballot is null) + var ballot = await AccountMergeLedger.WriteAsAsync(dbContext, user.Id, async standing => { - ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; - dbContext.SealedBallots.Add(ballot); - } - else - { - ballot.Revision++; - } + RequireStanding(standing); + var written = await dbContext.SealedBallots + .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); + if (written is null) + { + written = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; + dbContext.SealedBallots.Add(written); + } + else + { + written.Revision++; + } + + written.Commitment = commitment; + written.CommittedAtUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + return written; + }, cancellationToken); - ballot.Commitment = commitment; - ballot.CommittedAtUtc = now; - await dbContext.SaveChangesAsync(cancellationToken); await auditTrailService.RecordAsync( user.Id, nameof(Motion), motionId, "sealed.committed", $"{actor.DisplayName} committed a sealed ballot (revision {ballot.Revision}), commitment {commitment}.", @@ -159,36 +166,43 @@ public sealed class SealedBallotService( // RevealSeq and Status are concurrency tokens. A conflict means either the close froze the set (refuse, truthfully) or // another voter's reveal landed first while the window is still open (re-read the row and write again). - for (var attempt = 0; ; attempt++) + // Standing is read once, under the gate's lock, which holds across every attempt: a merge cannot freeze the account between them. + await AccountMergeLedger.WriteAsAsync(dbContext, user.Id, async standing => { - ballot.RevealedCandidate = candidate; - ballot.RevealedReason = reason; - ballot.Salt = document.Salt; - ballot.RevealedAtUtc = now; - motion.RevealSeq++; - try + RequireStanding(standing); + for (var attempt = 0; ; attempt++) { - await dbContext.SaveChangesAsync(cancellationToken); - break; - } - catch (DbUpdateConcurrencyException error) - { - foreach (var entry in error.Entries) + ballot.RevealedCandidate = candidate; + ballot.RevealedReason = reason; + ballot.Salt = document.Salt; + ballot.RevealedAtUtc = now; + motion.RevealSeq++; + try { - await entry.ReloadAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + break; } - - if (PhaseOf(motion, now) != SealedPhase.Reveal) + catch (DbUpdateConcurrencyException error) { - throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); - } + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } - if (attempt >= 4) - { - throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again."); + if (PhaseOf(motion, now) != SealedPhase.Reveal) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); + } + + if (attempt >= 4) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again."); + } } } - } + + return true; + }, cancellationToken); await auditTrailService.RecordAsync( user.Id, nameof(Motion), motionId, "sealed.revealed", @@ -359,6 +373,15 @@ public sealed class SealedBallotService( private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName; + // A member resolved before a merge froze the account must not write a ballot after it: the write gate reads standing at the write. + private static void RequireStanding(MergeStanding? standing) + { + if (standing is not null) + { + throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required."); + } + } + private static UserAccount RequireMember(CurrentBifrostActor actor) => actor is { IsActiveMember: true, UserAccount: { } user } ? user 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/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index 1a90c23..1fc0628 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -2,7 +2,9 @@ using System.Security.Claims; using Microsoft.AspNetCore; using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Desk; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Authentication; @@ -21,8 +23,10 @@ namespace Bifrost.Web.Features.Oidc; public static class OidcEndpoints { public const string SuspendedDescription = "The account is suspended."; + public const string MergedDescription = "The account is part of an account merge and can no longer sign in."; 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 +38,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."); @@ -53,7 +57,7 @@ public static class OidcEndpoints // every member or governance surface keeps asking for Active itself (the Active-member policy, IsActiveMember, ActiveMemberIdAsync). if (actor.UserAccount is null) { - return Deny(Errors.AccessDenied, NoIdentityDescription); + return Deny(Errors.AccessDenied, actor.Merge is not null ? MergedDescription : NoIdentityDescription); } if (actor.Membership?.Status == MembershipStatus.Suspended) @@ -83,6 +87,13 @@ public static class OidcEndpoints var principal = new ClaimsPrincipal(identity); principal.SetScopes(request.GetScopes()); + // A desk scope is what makes a token a desk token: its audience is the desk's. The client's permissions already + // decided whether it may ask for the scope (the MAS client may not), so this branch is reached only by one that may. + if (request.GetScopes().Any(DeskEndpoints.IsDeskScope)) + { + principal.SetResources(DeskEndpoints.Audience); + } + principal.SetDestinations(claim => claim.Type switch { Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken], @@ -91,19 +102,32 @@ public static class OidcEndpoints _ => [Destinations.AccessToken] }); + // The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11). + // 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 (!IsDeskToken(principal) && !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); } + /// A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt. + // Only a token whose every scope is a desk scope skips the consent gate: a client that may hold chat scopes too cannot ride a desk scope past the receipt. + private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes() is { Length: > 0 } scopes && scopes.All(DeskEndpoints.IsDeskScope); + 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, IsDeskToken(result.Principal)) is { } refusal) { - return Deny(Errors.InvalidGrant, SuspendedDescription); + return Deny(Errors.InvalidGrant, refusal); } return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); @@ -117,10 +141,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, IsDeskToken(result.Principal)) 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 +159,31 @@ 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, for a chat token (not a desk token), 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, bool deskToken = false) { 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; + } + + // A folded or frozen subject's chat sessions end: token, userinfo and the logout reconciler all read this one refusal. + if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null) + { + return MergedDescription; + } + + return deskToken || 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. @@ -158,6 +194,12 @@ public static class OidcEndpoints return null; } + // A folded or frozen subject's live token acts as nobody on the desk too: the same standing the cookie gate and ChatRefusalAsync read. + if (await AccountMergeLedger.StandingAsync(db, accountId, cancellationToken) is not null) + { + return null; + } + var account = await db.UserAccounts.AsNoTracking() .Include(x => x.Membership) .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); 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/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/Patronage/PatronageService.cs b/src/Bifrost.Web/Features/Patronage/PatronageService.cs index 8301356..e8cfbfe 100644 --- a/src/Bifrost.Web/Features/Patronage/PatronageService.cs +++ b/src/Bifrost.Web/Features/Patronage/PatronageService.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.EntityFrameworkCore; @@ -10,6 +11,13 @@ public sealed class PatronageService( AuditTrailService auditTrailService, TimeProvider timeProvider) { + private const string CurrentPatronIndex = "UX_TierSnapshots_MembershipId_CurrentPatron"; + + /// + /// Records a patron event for an account, through the merge ledger's write gate: an event for a folded account lands on its survivor + /// and is journalled in the same save (an undo moves it back); an event for a frozen account lands on it, where the commit's + /// reduction finds it. A provider retry returns the stored event and, if it sits on a folded account's survivor, journals it once. + /// public async Task RecordSupportEventAsync( Guid? actorUserAccountId, Guid userAccountId, @@ -27,105 +35,128 @@ public sealed class PatronageService( CancellationToken cancellationToken, Guid? projectId = null) { - var user = await dbContext.UserAccounts - .Include(x => x.Membership) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); - - if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended) - { - throw new InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account."); - } - - if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment) - { - throw new InvalidOperationException("Patron support amount must be greater than zero."); - } - - if (amount == 0) - { - throw new InvalidOperationException("Patron support amount must not be zero."); - } - - if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0) - { - throw new InvalidOperationException("Patron support adjustments must reduce support."); - } - var normalizedProviderEventId = providerEventId.Trim(); - if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId)) + var (supportEvent, recordedOn, isNew, recordedFor) = await AccountMergeLedger.WriteAsAsync(dbContext, userAccountId, async standing => { - throw new InvalidOperationException("External patron provider events require a provider event id."); - } + var folded = standing as MergeStanding.Folded; + var recordOnId = folded?.SurvivorId ?? userAccountId; + var now = timeProvider.GetUtcNow(); - var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId) - ? null - : await dbContext.PatronSupportEvents - .SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken); + var existingProviderEvent = string.IsNullOrWhiteSpace(normalizedProviderEventId) + ? null + : await dbContext.PatronSupportEvents + .SingleOrDefaultAsync(x => x.Provider == provider && x.ProviderEventId == normalizedProviderEventId, cancellationToken); - if (existingProviderEvent is not null) - { - return existingProviderEvent; - } - - var now = timeProvider.GetUtcNow(); - var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode) - ? "USD" - : currencyCode.Trim().ToUpperInvariant(); - - if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport) - { - var existingCurrentEvents = await dbContext.PatronSupportEvents - .Where(x => x.UserAccountId == userAccountId && - x.Kind == PatronSupportEventKind.RecurringSupportSnapshot && - x.IsCurrentRecurringSupport) - .ToListAsync(cancellationToken); - - foreach (var existing in existingCurrentEvents) + if (existingProviderEvent is not null) { - existing.IsCurrentRecurringSupport = false; + if (folded is not null && existingProviderEvent.UserAccountId == folded.SurvivorId) + { + await AccountMergeLedger.StageIntakeJournalAsync(dbContext, folded.MergeId, userAccountId, existingProviderEvent.Id, now, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + return (existingProviderEvent, recordOnId, false, string.Empty); } + + var user = await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleAsync(x => x.Id == recordOnId, cancellationToken); + + if (user.Membership is null || user.Membership.Status == MembershipStatus.Suspended) + { + throw new PatronSupportRefusedException(); + } + + if (amount <= 0 && kind != PatronSupportEventKind.SupportAdjustment) + { + throw new InvalidOperationException("Patron support amount must be greater than zero."); + } + + if (amount == 0) + { + throw new InvalidOperationException("Patron support amount must not be zero."); + } + + if (kind == PatronSupportEventKind.SupportAdjustment && amount > 0) + { + throw new InvalidOperationException("Patron support adjustments must reduce support."); + } + + if (provider != ExternalPatronProvider.Manual && string.IsNullOrWhiteSpace(normalizedProviderEventId)) + { + throw new InvalidOperationException("External patron provider events require a provider event id."); + } + + var normalizedCurrency = string.IsNullOrWhiteSpace(currencyCode) + ? "USD" + : currencyCode.Trim().ToUpperInvariant(); + + if (kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport) + { + var existingCurrentEvents = await dbContext.PatronSupportEvents + .Where(x => x.UserAccountId == recordOnId && + x.Kind == PatronSupportEventKind.RecurringSupportSnapshot && + x.IsCurrentRecurringSupport) + .ToListAsync(cancellationToken); + + foreach (var existing in existingCurrentEvents) + { + existing.IsCurrentRecurringSupport = false; + } + } + + var created = new PatronSupportEvent + { + UserAccountId = recordOnId, + ProjectId = projectId, + Kind = kind, + ExternalSupportId = externalSupportId.Trim(), + Provider = provider, + ProviderEventId = normalizedProviderEventId, + ProviderPayerId = providerPayerId.Trim(), + ProviderSubscriptionId = providerSubscriptionId.Trim(), + Amount = amount, + CurrencyCode = normalizedCurrency, + IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport, + SupportedAtUtc = supportedAtUtc.ToUniversalTime(), + RecordedAtUtc = now, + Notes = notes.Trim() + }; + + dbContext.PatronSupportEvents.Add(created); + dbContext.PointTransactions.Add(new PointTransaction + { + UserAccountId = recordOnId, + ProjectId = projectId, + Type = PointTransactionType.PatronSupport, + Amount = amount, + IsDecaying = kind == PatronSupportEventKind.OneTimeDonation, + CreatedAtUtc = now, + Note = $"Recorded {provider} {kind} patron support event {created.ExternalSupportId}." + }); + + if (folded is not null) + { + await AccountMergeLedger.StageIntakeJournalAsync(dbContext, folded.MergeId, userAccountId, created.Id, now, cancellationToken); + } + + await dbContext.SaveChangesAsync(cancellationToken); + return (created, recordOnId, true, user.DisplayName); + }, cancellationToken); + + if (isNew) + { + await auditTrailService.RecordAsync( + actorUserAccountId, + nameof(PatronSupportEvent), + supportEvent.Id, + "patron-support.recorded", + $"Recorded {amount:0.##} {supportEvent.CurrencyCode} {provider} {kind} patron support for {recordedFor}.", + cancellationToken); + + await RefreshPatronTierSnapshotAsync(actorUserAccountId, recordedOn, cancellationToken); } - var supportEvent = new PatronSupportEvent - { - UserAccountId = userAccountId, - ProjectId = projectId, - Kind = kind, - ExternalSupportId = externalSupportId.Trim(), - Provider = provider, - ProviderEventId = normalizedProviderEventId, - ProviderPayerId = providerPayerId.Trim(), - ProviderSubscriptionId = providerSubscriptionId.Trim(), - Amount = amount, - CurrencyCode = normalizedCurrency, - IsCurrentRecurringSupport = kind == PatronSupportEventKind.RecurringSupportSnapshot && isCurrentRecurringSupport, - SupportedAtUtc = supportedAtUtc.ToUniversalTime(), - RecordedAtUtc = now, - Notes = notes.Trim() - }; - - dbContext.PatronSupportEvents.Add(supportEvent); - dbContext.PointTransactions.Add(new PointTransaction - { - UserAccountId = userAccountId, - ProjectId = projectId, - Type = PointTransactionType.PatronSupport, - Amount = amount, - IsDecaying = kind == PatronSupportEventKind.OneTimeDonation, - CreatedAtUtc = now, - Note = $"Recorded {provider} {kind} patron support event {supportEvent.ExternalSupportId}." - }); - - await dbContext.SaveChangesAsync(cancellationToken); - await auditTrailService.RecordAsync( - actorUserAccountId, - nameof(PatronSupportEvent), - supportEvent.Id, - "patron-support.recorded", - $"Recorded {amount:0.##} {normalizedCurrency} {provider} {kind} patron support for {user.DisplayName}.", - cancellationToken); - - await RefreshPatronTierSnapshotAsync(actorUserAccountId, userAccountId, cancellationToken); return supportEvent; } @@ -134,24 +165,67 @@ public sealed class PatronageService( Guid userAccountId, CancellationToken cancellationToken) { - dbContext.ChangeTracker.Clear(); - - var user = await dbContext.UserAccounts - .Include(x => x.Membership) - .Include(x => x.PatronSupportEvents) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); - - if (user.Membership is null) + // One current Patron snapshot per membership is a database invariant (UX_TierSnapshots_MembershipId_CurrentPatron). A restage that + // lost a race to another restage of the same membership reads the winner's snapshot and derives again from the events it now sees. + UserAccount user; + PatronPointSummary summary; + PatronTierDefinition? tier; + for (var attempt = 0; ; attempt++) { - throw new InvalidOperationException("Cannot refresh patron tier for a user without membership."); + dbContext.ChangeTracker.Clear(); + + user = await dbContext.UserAccounts + .Include(x => x.Membership) + .Include(x => x.PatronSupportEvents) + .SingleAsync(x => x.Id == userAccountId, cancellationToken); + + if (user.Membership is null) + { + throw new InvalidOperationException("Cannot refresh patron tier for a user without membership."); + } + + (summary, tier) = await StageTierSnapshotAsync(user.Membership, user.PatronSupportEvents, timeProvider.GetUtcNow(), cancellationToken); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + break; + } + catch (DbUpdateException error) when (attempt < 3 && + error.InnerException is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation, ConstraintName: CurrentPatronIndex }) + { + } } - var summary = CalculatePatronPoints(user.PatronSupportEvents, timeProvider.GetUtcNow()); + await auditTrailService.RecordAsync( + actorUserAccountId, + nameof(TierSnapshot), + user.Membership.Id, + "patron-tier.derived", + tier is null + ? $"Cleared patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points." + : $"Derived {tier.Label} patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points.", + cancellationToken); + + return summary with { TierLabel = tier?.Label ?? string.Empty, VotingWeight = tier?.VotingWeight ?? 0m }; + } + + /// + /// Derives the membership's patron tier from the events once and stages it on the tracker: demotes the current Patron snapshots + /// and adds the new one. It never saves and never clears the tracker, so a caller can stage it inside a larger single save + /// (the account merge ledger does). + /// + public async Task<(PatronPointSummary Summary, PatronTierDefinition? Tier)> StageTierSnapshotAsync( + Bifrost.Web.Domain.Membership membership, + IEnumerable events, + DateTimeOffset now, + CancellationToken cancellationToken) + { + var summary = CalculatePatronPoints(events, now); var tier = PatronTierPolicy.ResolveTier(summary.EffectivePoints); - var now = timeProvider.GetUtcNow(); var currentPatronSnapshots = await dbContext.TierSnapshots - .Where(x => x.MembershipId == user.Membership.Id && + .Where(x => x.MembershipId == membership.Id && x.Kind == TierSnapshotKind.Patron && x.IsCurrent) .ToListAsync(cancellationToken); @@ -165,7 +239,7 @@ public sealed class PatronageService( { dbContext.TierSnapshots.Add(new TierSnapshot { - MembershipId = user.Membership.Id, + MembershipId = membership.Id, Kind = TierSnapshotKind.Patron, Label = PatronTierPolicy.SnapshotLabel(tier), Weight = tier.VotingWeight, @@ -176,18 +250,7 @@ public sealed class PatronageService( }); } - await dbContext.SaveChangesAsync(cancellationToken); - await auditTrailService.RecordAsync( - actorUserAccountId, - nameof(TierSnapshot), - user.Membership.Id, - "patron-tier.derived", - tier is null - ? $"Cleared patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points." - : $"Derived {tier.Label} patron tier for {user.DisplayName}; {summary.EffectivePoints:0.##} effective patron points.", - cancellationToken); - - return summary with { TierLabel = tier?.Label ?? string.Empty, VotingWeight = tier?.VotingWeight ?? 0m }; + return (summary, tier); } public static PatronPointSummary CalculatePatronPoints( @@ -215,6 +278,9 @@ public sealed class PatronageService( } } +/// The account patron support would record on has no membership or is suspended. +public sealed class PatronSupportRefusedException() : InvalidOperationException("Patron support can only be recorded for an unsuspended Bifrost account."); + public sealed record PatronPointSummary( decimal RecurringPoints, decimal HistoricalPoints, diff --git a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs index 4293ffe..4dbcda7 100644 --- a/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs +++ b/src/Bifrost.Web/Features/Patronage/StripeWebhookService.cs @@ -66,7 +66,7 @@ public sealed class StripeWebhookService( var patronageAccount = await ResolvePatronageAccountAsync(session, stripeOptions, cancellationToken); - if (patronageAccount?.Membership is null || patronageAccount.Membership.Status == MembershipStatus.Suspended) + if (patronageAccount is null) { return StripeWebhookResult.NotConfigured( "Stripe checkout session is not linked to an unsuspended Bifrost patron account."); @@ -83,22 +83,32 @@ public sealed class StripeWebhookService( ? "USD" : session.Currency.ToUpperInvariant(); - await patronageService.RecordSupportEventAsync( - actorUserAccountId: null, - patronageAccount.Id, - PatronSupportEventKind.OneTimeDonation, - amount, - currencyCode, - session.Id, - ExternalPatronProvider.Stripe, - stripeEvent.Id, - session.Customer, - string.Empty, - DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created), - isCurrentRecurringSupport: false, - BuildNotes(session), - cancellationToken, - projectId.ProjectId); + // A checkout opened by an account that has since folded pays the survivor: the write gate behind RecordSupportEventAsync routes it. + PatronSupportEvent supportEvent; + try + { + supportEvent = await patronageService.RecordSupportEventAsync( + actorUserAccountId: null, + patronageAccount.Id, + PatronSupportEventKind.OneTimeDonation, + amount, + currencyCode, + session.Id, + ExternalPatronProvider.Stripe, + stripeEvent.Id, + session.Customer, + string.Empty, + DateTimeOffset.FromUnixTimeSeconds(stripeEvent.Created), + isCurrentRecurringSupport: false, + BuildNotes(session), + cancellationToken, + projectId.ProjectId); + } + catch (PatronSupportRefusedException) + { + return StripeWebhookResult.NotConfigured( + "Stripe checkout session is not linked to an unsuspended Bifrost patron account."); + } return StripeWebhookResult.Processed("Stripe patronage support event recorded."); } 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/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs index 1a3cd0f..f7cf59b 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,47 @@ public sealed class ApplicationBootstrapper( } } + /// + /// The one bootstrap-admin promotion, used at startup and at sign-in. A Merged or 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.IsMerged) + { + logger.LogWarning("Bootstrap admin promotion skipped for merged account {UserAccountId}.", account.Id); + return false; + } + + 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/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/Features/Shared/CurrentBifrostActor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs index 648c662..412dada 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActor.cs @@ -1,9 +1,13 @@ using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; namespace Bifrost.Web.Features.Shared; public sealed record CurrentBifrostActor(bool IsAuthenticated, UserAccount? UserAccount) { + /// Set when the account behind this sign-in is frozen or folded by an account merge; the actor then has no account. + public MergeStanding? Merge { get; init; } + public static readonly CurrentBifrostActor Anonymous = new(false, null); public Bifrost.Web.Domain.Membership? Membership => UserAccount?.Membership; diff --git a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs index 5d34fad..ae9839b 100644 --- a/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs +++ b/src/Bifrost.Web/Features/Shared/CurrentBifrostActorAccessor.cs @@ -12,7 +12,8 @@ public interface ICurrentBifrostActorAccessor public sealed class CurrentBifrostActorAccessor( IHttpContextAccessor httpContextAccessor, - BifrostDbContext dbContext) : ICurrentBifrostActorAccessor + BifrostDbContext dbContext, + AccountMergeLedger ledger) : ICurrentBifrostActorAccessor { private const string CacheKey = "Bifrost.CurrentActor"; @@ -45,18 +46,41 @@ public sealed class CurrentBifrostActorAccessor( return Cache(httpContext, CurrentBifrostActor.Anonymous); } - var userAccount = await dbContext.UserAccounts + // One credential names one row: identity first, then Heimdall id, then GitHub id; the first hit wins. Two rows matching + // one principal (a merge can leave a login on either side) must never throw or pick by luck. + var accounts = dbContext.UserAccounts .AsNoTracking() .Include(x => x.MemberProfile) .Include(x => x.Membership!) .ThenInclude(x => x.RoleAssignments) .Include(x => x.Membership!) - .ThenInclude(x => x.TierSnapshots) - .SingleOrDefaultAsync( - x => (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity) && x.NormalizedBifrostIdentity == normalizedBifrostIdentity) || - (!string.IsNullOrWhiteSpace(heimdallAccountId) && x.HeimdallAccountId == heimdallAccountId) || - (hasGitHubUserId && x.GitHubUserId == gitHubUserId), - cancellationToken); + .ThenInclude(x => x.TierSnapshots); + + Bifrost.Web.Domain.UserAccount? userAccount = null; + if (!string.IsNullOrWhiteSpace(normalizedBifrostIdentity)) + { + userAccount = await accounts.SingleOrDefaultAsync(x => x.NormalizedBifrostIdentity == normalizedBifrostIdentity, cancellationToken); + } + + if (userAccount is null && !string.IsNullOrWhiteSpace(heimdallAccountId)) + { + userAccount = await accounts.SingleOrDefaultAsync(x => x.HeimdallAccountId == heimdallAccountId, cancellationToken); + } + + if (userAccount is null && hasGitHubUserId) + { + userAccount = await accounts.SingleOrDefaultAsync(x => x.GitHubUserId == gitHubUserId, cancellationToken); + } + + // A folded or frozen account acts as nobody on every route: this is the one gate (merge ledger standing, derived per request). + if (userAccount is not null) + { + var standing = await ledger.StandingAsync(userAccount.Id, cancellationToken); + if (standing is not null) + { + return Cache(httpContext, new CurrentBifrostActor(true, null) { Merge = standing }); + } + } return Cache(httpContext, new CurrentBifrostActor(true, userAccount)); } 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/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 3181450..58fe2fb 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -15,12 +15,16 @@ public sealed class StartupConfigurationValidator( IOptions hostOptions, IOptions conceptVoteOptions, IOptions deskOptions, - IOptions registrationOptions) + IOptions registrationOptions, + OidcIssuerOptions? oidcOptions = null) { public void Validate() { 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)) @@ -101,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) @@ -149,6 +160,12 @@ public sealed class StartupConfigurationValidator( // mis-attribute answers. Account existence is a request-time question, not a startup one. private void ValidateDesk(DeskOptions desk, List failures) { + // The desk's bearer is validated against Bifrost's own issuer: without Bifrost:Oidc there is no credential to serve with. + if (oidcOptions is null && configuration.GetSection(DeskOptions.SectionName).Exists()) + { + failures.Add("Bifrost:Oidc must be configured when the Desk section exists: the desk validates its bearer tokens against Bifrost's own issuer."); + } + if (!desk.IsConfigured) { if (configuration.GetSection(DeskOptions.SectionName).Exists()) diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml b/src/Bifrost.Web/Pages/App/Index.cshtml index 889675a..c3406be 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml +++ b/src/Bifrost.Web/Pages/App/Index.cshtml @@ -14,12 +14,7 @@ governance pressure, and recent team activity in one place.

- + Refresh snapshot
diff --git a/src/Bifrost.Web/Pages/App/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Index.cshtml.cs index ed0db5c..9d8a800 100644 --- a/src/Bifrost.Web/Pages/App/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Index.cshtml.cs @@ -18,11 +18,4 @@ public sealed class IndexModel( Actor = await actorAccessor.GetAsync(cancellationToken); Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken); } - - public async Task OnGetSummaryAsync(CancellationToken cancellationToken) - { - Actor = await actorAccessor.GetAsync(cancellationToken); - Snapshot = await snapshotService.GetAsync(Actor.UserAccount?.Id, cancellationToken); - return Partial("_SummaryCards", Snapshot); - } } diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml b/src/Bifrost.Web/Pages/App/Members/Index.cshtml index b4d67e5..6521c2b 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.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 e9b536b..caa536b 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -22,6 +22,27 @@ 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."; + /// 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 ApproveMergedNotice = "Nothing changed: that account was merged into another and cannot be approved."; + + 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; } = []; @@ -102,11 +123,25 @@ 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) { - return RedirectToPage(); // only Unsuspend lifts a suspension + 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(); + } + + if (user.Membership.IsMerged) + { + Notice = ApproveMergedNotice; // only the merge ledger leaves Merged + return RedirectToPage(); } user.Membership.Status = MembershipStatus.Active; @@ -143,7 +178,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) @@ -157,10 +198,29 @@ 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) { + 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 +235,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); } @@ -220,7 +282,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) { @@ -339,6 +407,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 +495,7 @@ public sealed class IndexModel( string Skills, string Availability, MembershipStatus Status, + string SuspensionReason, IReadOnlyList Roles, string PatronTierLabel, decimal PatronTierWeight, diff --git a/src/Bifrost.Web/Pages/App/Merges/Index.cshtml b/src/Bifrost.Web/Pages/App/Merges/Index.cshtml new file mode 100644 index 0000000..1f8d341 --- /dev/null +++ b/src/Bifrost.Web/Pages/App/Merges/Index.cshtml @@ -0,0 +1,72 @@ +@page +@using Bifrost.Web.Domain +@model Bifrost.Web.Pages.App.Merges.IndexModel +@{ + ViewData["Title"] = "Stuck merges"; +} + +
+
+ Merges +

Stuck account merges

+

+ A merge listed here has stopped advancing on its own. Before ending one, read Heimdall's identity move by the move id shown + (docs/account-merge.md, Stuck merges). Ending a merge cannot be undone, and Bifrost never adopts a move Heimdall made. +

+ + @if (!string.IsNullOrEmpty(Model.Notice)) + { + + } +
+
+ +
+ @if (Model.Merges.Count == 0) + { +
No merge is stuck.
+ } + else + { +
+ + + + + + + + + + + + + @foreach (var merge in Model.Merges) + { + + + + + + + + + } + +
MergeStateLast outcomeAttemptsMove id of this legEnd it
@merge.Id@merge.State@merge.LastOutcome@merge.Attempts@merge.MoveId + @if (!merge.CanSettle) + { + Still being retried; nothing to end. + } + else + { + + + + + + } +
+
+ } +
diff --git a/src/Bifrost.Web/Pages/App/Merges/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Merges/Index.cshtml.cs new file mode 100644 index 0000000..865cb42 --- /dev/null +++ b/src/Bifrost.Web/Pages/App/Merges/Index.cshtml.cs @@ -0,0 +1,82 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Pages.App.Merges; + +/// One in-flight merge the sweeper has stopped advancing, as an operator may see it: no account, Heimdall id or login of either side. +public sealed record StuckMerge(Guid Id, AccountMergeState State, string LastOutcome, int Attempts, string MoveId, bool CanSettle); + +/// +/// The operator's door for a stuck merge (ruling merge-stuck-end-only-typed-id), platform admins only. It lists in-flight merges that +/// stopped on a settleable outcome or have been retried eight times, with the stored move id of the leg in flight, and passes an +/// admin's choice and the typed move id to , the one writer. It calls no Heimdall. +/// +public sealed class IndexModel(BifrostDbContext dbContext, ICurrentBifrostActorAccessor actorAccessor, AccountMergeSaga saga) : PageModel +{ + private const int RetriesBeforeListed = 8; + + public const string Url = "/App/Merges"; + + private static readonly IReadOnlyDictionary ResultWords = new Dictionary + { + ["settled"] = "The merge was ended and the sweeper will not touch it again.", + ["not_stuck"] = "Nothing changed: that merge is not stuck on a state this choice can end.", + ["wrong_move_id"] = "Nothing changed: the move id typed is not the stored one of that leg.", + ["lost"] = "Nothing changed: the merge moved on by itself while you were settling it." + }; + + public IReadOnlyList Merges { get; private set; } = []; + + /// The words for the closed ?result= code of the last settle; null for no code or one this page does not know. + public string? Notice { get; private set; } + + public async Task OnGetAsync(string? result, CancellationToken cancellationToken) + { + if (!(await actorAccessor.GetAsync(cancellationToken)).IsPlatformAdmin) + { + return Forbid(); + } + + Notice = result is not null && ResultWords.TryGetValue(result, out var words) ? words : null; + var rows = await dbContext.AccountMerges.AsNoTracking() + .Where(x => (x.State == AccountMergeState.Confirmed || x.State == AccountMergeState.UndoRequested) + && (AccountMergeSaga.SettleableOutcomes.Contains(x.LastOutcome) || x.Attempts >= RetriesBeforeListed)) + .OrderBy(x => x.CreatedUtc) + .ToListAsync(cancellationToken); + Merges = [.. rows.Select(x => new StuckMerge( + x.Id, x.State, x.LastOutcome, x.Attempts, + (x.State == AccountMergeState.UndoRequested ? x.HeimdallReverseMoveId : x.HeimdallMoveId) ?? string.Empty, + AccountMergeSaga.IsSettleable(x)))]; + return Page(); + } + + public Task OnPostFailForwardAsync(Guid mergeId, string? moveId, CancellationToken cancellationToken) => + SettleAsync(mergeId, SettleChoice.FailForward, moveId, cancellationToken); + + public Task OnPostAbandonUndoAsync(Guid mergeId, string? moveId, CancellationToken cancellationToken) => + SettleAsync(mergeId, SettleChoice.AbandonUndo, moveId, cancellationToken); + + private async Task SettleAsync(Guid mergeId, SettleChoice choice, string? moveId, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + if (!actor.CanManageMembership || actor.UserAccount is null) + { + return Forbid(); + } + + var outcome = await saga.SettleStuckAsync(mergeId, choice, moveId ?? string.Empty, actor.UserAccount.Id, cancellationToken); + var code = outcome switch + { + SettleOutcome.Settled => "settled", + SettleOutcome.WrongMoveId => "wrong_move_id", + SettleOutcome.Lost => "lost", + _ => "not_stuck" + }; + return Redirect($"{Url}?result={code}"); + } +} diff --git a/src/Bifrost.Web/Pages/Index.cshtml b/src/Bifrost.Web/Pages/Index.cshtml index 76dd401..cfaaacd 100644 --- a/src/Bifrost.Web/Pages/Index.cshtml +++ b/src/Bifrost.Web/Pages/Index.cshtml @@ -64,7 +64,7 @@
Stack - ASP.NET Core 8 + Razor Pages + HTMX + ASP.NET Core 8 + Razor Pages
System of record diff --git a/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml new file mode 100644 index 0000000..ad86138 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml @@ -0,0 +1,237 @@ +@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) + { +

+ 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 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.OrderBy(x => x == "google" ? 0 : 1)) + { +
+ + +
+ } +
+ } + 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.HasStrongPasskey) + { + @if (Model.PasskeysAvailable) + { +
    + + + + + + + + +
    + } + } + else if (Model.HasGoogle) + { +
    + + +
    + } +
  • + } +
+ @if (Model.Identities.Count == 1) + { +

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

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

+ 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 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. +

+ } + +
+ +
+ } + +

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

+

+ 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.MergeEnabled) + { +

+ Hold two accounts? Merge two accounts +

+ } + } + + +
+ +@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 new file mode 100644 index 0000000..bfc44c5 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/LinkedAccounts.cshtml.cs @@ -0,0 +1,316 @@ +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.Filters; +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, + PasskeyService passkeys, + ICurrentBifrostActorAccessor actors, + IOptions hostOptions, + IOptions mergeOptions, + ILogger logger) : PageModel +{ + private static readonly Dictionary ProviderNames = new(StringComparer.Ordinal) + { + ["discord"] = "Discord", + ["patreon"] = "Patreon", + ["google"] = "Google" + }; + + /// + /// A folded or frozen account manages no logins and enrols no passkey: the page's handlers all begin a Heimdall flow on the + /// account's raw claim, so the one gate is here, on the actor accessor's standing, before any handler runs. + /// + public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + if ((await actors.GetAsync(context.HttpContext.RequestAborted)).Merge is not null) + { + context.Result = Forbid(); + return; + } + + await base.OnPageHandlerExecutionAsync(context, next); + } + + private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null; + + /// Whether the merge page exists on this server (a flag, dark until Heimdall can move logins). + public bool MergeEnabled => mergeOptions.Value.Enabled; + + /// 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: 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 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. + 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, and so does a missing list. + public Task OnPostLinkAsync(string? provider, CancellationToken cancellationToken) + { + if (provider is null || !HeimdallAuthService.LinkableProviders.Contains(provider)) + { + return Task.FromResult(Done(HeimdallLinkOutcome.Error)); + } + + if (AccountId is not { } id || snapshots.Get(id) is null) + { + return Task.FromResult(Done(HeimdallLinkOutcome.ListExpired)); + } + + return BeginAsync( + (account, ct) => heimdallAuthService.StartLinkAsync(account, 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 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 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 Done(HeimdallLinkOutcome.ProofUnavailable); + } + + 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) }); + + 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/MergeAccounts.cshtml b/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml new file mode 100644 index 0000000..1b72e29 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml @@ -0,0 +1,148 @@ +@page +@model Bifrost.Web.Pages.Membership.MergeAccountsModel +@{ + ViewData["Title"] = "Merge two accounts"; +} + +
+ Account merge +

Merge two accounts

+

+ If one person holds two accounts, they can be merged into one. You prove both accounts with a fresh sign-in each, choose + which one stays, see what changes and confirm; nothing is named until both proofs exist, and the other account is only + ever the one Heimdall identified from your second sign-in. +

+ + @if (Model.ResultMessage is not null) + { +
@Model.ResultMessage
+ } + + @if (Model.ConfirmMessage is not null) + { +
@Model.ConfirmMessage
+ } + + @if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.Prove) + { +

Prove both accounts

+

+ Sign-ins are held in memory for a few minutes and then dropped. For each account, name a provider that account holds: + signing in through a provider it does not hold would make an unrelated account. +

+ +

This account

+ @if (Model.SelfProved) + { +

This account is proved.

+ } + else + { +
+ @foreach (var provider in Model.LinkableProviders) + { +
+ + +
+ } +
+ } + +

The other account

+ @if (Model.OtherProved) + { +

The other account is proved.

+ } + else + { +
+ @foreach (var provider in Model.LinkableProviders) + { +
+ + +
+ } +
+ } + } + else + { +

The two accounts

+
    +
  • This account: @Model.SelfName
  • +
  • The other account: @Model.OtherName
  • +
+ + @if (Model.Keep is null) + { +

Choose the account that stays. The other one is folded into it.

+ + } + else + { +

Keep @Model.KeptName

+

@Model.FoldedName is folded into it.

+
    + @foreach (var line in Model.PreviewLines) + { +
  • @line
  • + } +
+ @foreach (var statement in Model.Statements) + { +

@statement

+ } + +

+ Keep @Model.FoldedName instead +

+ + @if (Model.BlockWords.Count > 0) + { +
+
    + @foreach (var words in Model.BlockWords) + { +
  • @words
  • + } +
+
+ } + else if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.ProveRemaining) + { +

+ @Model.FoldedName holds more logins. Sign in once more through each one, so Heimdall knows the whole account agrees. +

+
+ @foreach (var provider in Model.ProvidersToProve) + { +
+ + + +
+ } +
+ } + else if (Model.Stage == Bifrost.Web.Pages.Membership.MergeAccountsModel.MergeStage.Confirm) + { +
+ + +

+ +

+

+ +

+ +
+ } + } + } +
diff --git a/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs b/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs new file mode 100644 index 0000000..1b2e0c1 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/MergeAccounts.cshtml.cs @@ -0,0 +1,270 @@ +using System.Security.Claims; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +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.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Membership; + +/// +/// Where a member merges two accounts: proves both by fresh sign-ins, chooses which one stays against a preview, ticks one box for each +/// account and confirms. The page owns only that choice and that consent. Its stage is derived from and the +/// query on every request, nothing about progress is kept in a cookie or a hidden field except the chosen direction and the preview's +/// digest, and both are re-checked by the saga. The other account is named (by display name only) once both proofs are held, and +/// found by no email, name or login string: it is the account Heimdall identified from the second proof. The page writes no merge, +/// vote, membership or journal row: inserts the one Confirmed row that is the consent. +/// +[Authorize] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class MergeAccountsModel( + ICurrentBifrostActorAccessor actors, + BifrostDbContext dbContext, + AccountMergeLedger ledger, + AccountMergeSaga saga, + MergeProofs proofs, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + IOptions mergeOptions, + ILogger logger) : PageModel +{ + private const string KeepParameter = "keep"; + private const string MergeParameter = "merge"; + private const string Self = "self"; + private const string Other = "other"; + + public enum MergeStage + { + /// One side or both lack a proof: nothing is named. + Prove, + + /// Both sides are proved: the member chooses which stays. + Choose, + + /// A direction is chosen and the folded account's remaining logins are asked one sign-in each. + ProveRemaining, + + /// Everything the saga needs is held: the confirm form. + Confirm + } + + public MergeStage Stage { get; private set; } = MergeStage.Prove; + + public string? ResultMessage { get; private set; } + + public string? ConfirmMessage { get; private set; } + + public bool SelfProved { get; private set; } + + public bool OtherProved { get; private set; } + + /// Display names, set only once both sides are proved. + public string? SelfName { get; private set; } + + public string? OtherName { get; private set; } + + /// The chosen direction, "self" or "other", or null. + public string? Keep { get; private set; } + + public string? KeptName => Keep == Self ? SelfName : Keep == Other ? OtherName : null; + + public string? FoldedName => Keep == Self ? OtherName : Keep == Other ? SelfName : null; + + public IReadOnlyList PreviewLines { get; private set; } = []; + + public IReadOnlyList BlockWords { get; private set; } = []; + + public IReadOnlyList Statements => MergePreview.Statements; + + public IReadOnlyList ProvidersToProve { get; private set; } = []; + + public string Digest { get; private set; } = string.Empty; + + public IReadOnlyList LinkableProviders => HeimdallAuthService.LinkableProviders; + + public static string ProviderName(string provider) => MergeStatusModel.ProviderName(provider); + + private string? AccountId => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 } id ? id : null; + + /// Off, the page does not exist; a folded or frozen account is forbidden it, as it is Linked accounts. + public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + if (!mergeOptions.Value.Enabled) + { + context.Result = NotFound(); + return; + } + + var actor = await actors.GetAsync(context.HttpContext.RequestAborted); + if (actor.Merge is not null || actor.UserAccount is null || AccountId is null) + { + context.Result = Forbid(); + return; + } + + await base.OnPageHandlerExecutionAsync(context, next); + } + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); + ConfirmMessage = MergeWording.Confirm(Request.Query[MergeParameter].ToString()) is { Length: > 0 } words ? words : null; + + var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id; + var held = proofs.Snapshot(me); + SelfProved = held.Self is not null; + OtherProved = held.Other is not null; + if (held.Self is not { } self || held.Other is not { } other) + { + return; + } + + var names = await dbContext.UserAccounts.AsNoTracking() + .Where(x => x.Id == self.UserAccountId || x.Id == other.UserAccountId) + .ToDictionaryAsync(x => x.Id, x => x.DisplayName, cancellationToken); + SelfName = names.GetValueOrDefault(self.UserAccountId); + OtherName = names.GetValueOrDefault(other.UserAccountId); + Stage = MergeStage.Choose; + if (SideOf(Request.Query[KeepParameter].ToString()) is not { } survivorSide) + { + return; + } + + Keep = survivorSide == MergeSide.Self ? Self : Other; + var (survivor, folded) = survivorSide == MergeSide.Self ? (self, other) : (other, self); + var plan = await ledger.PlanAsync(survivor.UserAccountId, folded.UserAccountId, cancellationToken); + Digest = plan.Digest(survivor.UserAccountId, folded.UserAccountId); + PreviewLines = MergePreview.Lines(plan); + BlockWords = [.. plan.Blocks.Select(MergePreview.Block)]; + switch (proofs.Peek(me, survivorSide)) + { + case PeekResult.ProviderConflict conflict: + BlockWords = [.. BlockWords, MergePreview.ProviderConflict(conflict.Providers.Select(ProviderName))]; + break; + case PeekResult.Ready when plan.Blocks.Count == 0: + Stage = MergeStage.Confirm; + break; + case PeekResult.LoginsUnproved unproved: + ProvidersToProve = unproved.Providers; + Stage = MergeStage.ProveRemaining; + break; + } + } + + /// Starts a fresh sign-in of the account signed in here. + public Task OnPostProveSelfAsync(string? via, CancellationToken cancellationToken) => BeginAsync(MergeSide.Self, via, cancellationToken); + + /// Starts a fresh sign-in of the other account. The member names the provider: Bifrost never guesses one. + public Task OnPostProveOtherAsync(string? via, CancellationToken cancellationToken) => BeginAsync(MergeSide.Other, via, cancellationToken); + + /// Starts the next sign-in the chosen folded account still owes; its side follows from the chosen direction. + public Task OnPostProveRemainingAsync(string? via, string? keep, CancellationToken cancellationToken) => + SideOf(keep) is { } survivorSide + ? BeginAsync(survivorSide == MergeSide.Self ? MergeSide.Other : MergeSide.Self, via, cancellationToken) + : Task.FromResult(Refused(null, "bad_choice")); + + /// + /// The member's consent: both boxes, the direction and the digest of the preview they saw. A refusal writes nothing and keeps the + /// proofs; only a Confirmed row spends them. + /// + public async Task OnPostConfirmAsync(string? keep, string? digest, bool consentSelf, bool consentOther, CancellationToken cancellationToken) + { + if (SideOf(keep) is not { } survivorSide) + { + return Refused(null, "bad_choice"); + } + + if (!consentSelf || !consentOther) + { + return Refused(keep, "consent_needed"); + } + + var me = (await actors.GetAsync(cancellationToken)).UserAccount!.Id; + var held = proofs.Snapshot(me); + var peeked = proofs.Peek(me, survivorSide); + if (peeked is PeekResult.ProviderConflict) + { + return Refused(keep, "blocked"); + } + + if (held.Self is not { } self || held.Other is not { } other || peeked is not PeekResult.Ready ready) + { + return Refused(keep, "proofs_needed"); + } + + var (survivor, folded) = survivorSide == MergeSide.Self ? (self, other) : (other, self); + var outcome = await saga.ConfirmAsync( + new ConfirmMergeRequest(me, survivor.UserAccountId, folded.UserAccountId, digest ?? string.Empty, ready.Survivor, ready.Folded), cancellationToken); + switch (outcome) + { + case ConfirmOutcome.Confirmed confirmed: + proofs.Clear(me); + try + { + await saga.AdvanceAsync(confirmed.MergeId, cancellationToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + // The stored state is the truth and the sweeper resumes it. + logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", confirmed.MergeId, error.GetType().Name); + } + + return RedirectToPage("/Membership/MergeStatus"); + case ConfirmOutcome.Blocked: + return Refused(keep, "blocked"); + case ConfirmOutcome.PreviewStale: + return Refused(keep, "preview_stale"); + case ConfirmOutcome.NotParty: + return Refused(keep, "not_party"); + default: + return Refused(keep, "proof_account_mismatch"); + } + } + + private static MergeSide? SideOf(string? keep) => keep switch + { + Self => MergeSide.Self, + Other => MergeSide.Other, + _ => null + }; + + private async Task BeginAsync(MergeSide side, string? via, CancellationToken cancellationToken) + { + try + { + var start = side == MergeSide.Self + ? (Func>)(id => heimdallAuthService.StartMergeProveSelfAsync( + id, via, ReturnPath, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken)) + : id => heimdallAuthService.StartMergeProveOtherAsync( + id, via, ReturnPath, hostOptions.Value.PublicBaseUrl, Request.Cookies, cancellationToken); + var started = await start(AccountId!); + Response.Cookies.Append(started.CookieName, started.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(started.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Merge proof start refused: {Reason}", error.Message); + return RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + } + + private const string ReturnPath = "/Membership/MergeAccounts"; + + private IActionResult Refused(string? keep, string code) + { + var query = new Dictionary(); + if (keep is Self or Other) + { + query[KeepParameter] = keep; + } + + query[MergeParameter] = code; + return RedirectToPage(null, query); + } +} diff --git a/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml new file mode 100644 index 0000000..95f2d32 --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml @@ -0,0 +1,100 @@ +@page +@model Bifrost.Web.Pages.Membership.MergeStatusModel +@{ + ViewData["Title"] = "Merge status"; +} + +
+ Account merge +

Merge status

+

+ A merge folds one account into another. The account it kept can undo it for seven days, with a fresh sign-in for + every login that moved. +

+ + @if (Model.ResultMessage is not null) + { +
@Model.ResultMessage
+ } + + @if (Model.UndoMessage is not null) + { +
@Model.UndoMessage
+ } + + @if (Model.Merges.Count == 0) + { +

There is no merge to show for this account.

+ } + + @foreach (var merge in Model.Merges) + { +
+ @merge.State + @if (merge.Why is not null) + { +

@merge.Why

+ } + @if (merge.Outcome is not null) + { +

@merge.Outcome

+ } +
    + @if (merge.Confirmed is not null) + { +
  • Confirmed @merge.Confirmed
  • + } + @if (merge.Committed is not null) + { +
  • Merged @merge.Committed
  • + } + @if (merge.GraceEnds is not null) + { +
  • Undo window ends @merge.GraceEnds
  • + } + @if (merge.Final is not null) + { +
  • Final @merge.Final
  • + } + @if (merge.SurvivorProofs.Count > 0) + { +
  • Kept account proved with: @string.Join(", ", merge.SurvivorProofs)
  • + } + @if (merge.FoldedProofs.Count > 0) + { +
  • Folded account proved with: @string.Join(", ", merge.FoldedProofs)
  • + } +
+ @if (merge.DaysLeft is not null) + { +

@merge.DaysLeft

+ } + @if (merge.TierNote is not null) + { +

@merge.TierNote

+ } + + @if (merge.CanUndo) + { + @if (merge.Ready) + { +
+ + +
+ } + else + { +

To undo, sign in again through each login that moved. The sign-ins are held in memory for a few minutes.

+ @foreach (var provider in merge.NeedProof) + { +
+ + +
+ } + } + } +
+ } +
diff --git a/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs new file mode 100644 index 0000000..00cc86f --- /dev/null +++ b/src/Bifrost.Web/Pages/Membership/MergeStatus.cshtml.cs @@ -0,0 +1,206 @@ +using System.Security.Claims; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +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; + +/// +/// The member's own merge page: the merges that kept this account (up to five) and the one its frozen or folded standing names, in +/// 's closed words, and the undo. It reads merge rows and writes none: undo is the saga's +/// followed by its own , with fresh proofs +/// of every moved login held by . A person who signed in through a login that moved is the survivor here, so +/// there is no separate folded-side undo. Nothing the query, the form or the store holds is shown except as a closed code's words, and +/// no account name, id, email or login of either side is rendered. +/// +[Authorize] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class MergeStatusModel( + ICurrentBifrostActorAccessor actors, + BifrostDbContext dbContext, + AccountMergeSaga saga, + MergeProofs proofs, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + TimeProvider timeProvider, + ILogger logger) : PageModel +{ + private const int MaxListed = 5; + private const string UndoParameter = "undo"; + private const string LoginsUnproved = "logins_unproved"; + + public sealed record MergeRow( + Guid Id, + string State, + string? Why, + string? Outcome, + string? Confirmed, + string? Committed, + string? GraceEnds, + string? Final, + string? DaysLeft, + string? TierNote, + IReadOnlyList SurvivorProofs, + IReadOnlyList FoldedProofs, + bool CanUndo, + bool Ready, + IReadOnlyList NeedProof); + + public IReadOnlyList Merges { get; private set; } = []; + + public string? ResultMessage { get; private set; } + + public string? UndoMessage { get; private set; } + + public static string ProviderName(string provider) => + HeimdallAuthService.LinkableProviders.Contains(provider) ? LinkedAccountsModel.ProviderName(provider) : provider == "passkey" ? "Passkey" : "another provider"; + + public async Task OnGetAsync(CancellationToken cancellationToken) + { + ResultMessage = HeimdallLinkOutcomes.Wording(Request.Query[HeimdallLinkOutcomes.ResultParameter].ToString()); + UndoMessage = MergeWording.Undo(Request.Query[UndoParameter].ToString()) is { Length: > 0 } words ? words : null; + + var actor = await actors.GetAsync(cancellationToken); + var me = actor.UserAccount?.Id; + var rows = me is { } id + ? await dbContext.AccountMerges.AsNoTracking().Where(x => x.SurvivorUserAccountId == id).OrderByDescending(x => x.CreatedUtc).Take(MaxListed).ToListAsync(cancellationToken) + : []; + if (actor.Merge is { } standing && rows.All(x => x.Id != standing.MergeId) + && await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == standing.MergeId, cancellationToken) is { } named) + { + rows.Add(named); + } + + Merges = [.. rows.Select(x => Row(x, me))]; + } + + /// Starts a fresh sign-in of this account through , held in memory until the undo takes it. + public async Task OnPostProveForUndoAsync(string? via, CancellationToken cancellationToken) + { + if (User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is not { Length: > 0 } accountId) + { + return Result(HeimdallLinkOutcome.Error); + } + + try + { + var started = await heimdallAuthService.StartMergeProveSelfAsync( + accountId, via, HeimdallLinkReturn, 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("Merge undo proof start refused: {Reason}", error.Message); + return Result(HeimdallLinkOutcome.Error); + } + } + + /// Asks the saga to undo one merge with the proofs this member holds, then runs its step function once, as the sweeper would. + public async Task OnPostUndoAsync(string? mergeId, CancellationToken cancellationToken) + { + var actor = await actors.GetAsync(cancellationToken); + if (actor.UserAccount is not { } me || !Guid.TryParse(mergeId, out var id) + || await dbContext.AccountMerges.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id, cancellationToken) is not { } merge + || merge.SurvivorUserAccountId != me.Id) + { + return Undone("not_survivor"); + } + + if (proofs.PeekUndo(me.Id, MovedIds(merge)) is not UndoPeekResult.Ready { Proofs.Count: > 0 } ready) + { + return Undone(LoginsUnproved); + } + + var outcome = await saga.RequestUndoAsync(id, me.Id, ready.Proofs, cancellationToken); + if (outcome is UndoOutcome.Requested) + { + proofs.Clear(me.Id); + try + { + await saga.AdvanceAsync(id, cancellationToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + // The saga's stored state is the truth and the sweeper resumes it. + logger.LogWarning("Account merge {MergeId} inline advance failed: {Error}", id, error.GetType().Name); + } + } + + return Undone(MergeWording.UndoCode(outcome)); + } + + private const string HeimdallLinkReturn = "/Membership/MergeStatus"; + + private MergeRow Row(AccountMerge merge, Guid? me) + { + var now = timeProvider.GetUtcNow(); + var open = merge.State == AccountMergeState.Committed && merge.GraceEndsUtc is { } end && now < end; + var canUndo = open && me == merge.SurvivorUserAccountId; + var ready = false; + IReadOnlyList need = []; + if (canUndo) + { + switch (proofs.PeekUndo(me!.Value, MovedIds(merge))) + { + case UndoPeekResult.Ready { Proofs.Count: > 0 }: + ready = true; + break; + case UndoPeekResult.LoginsUnproved { Providers.Count: > 0 } unproved: + need = unproved.Providers; + break; + default: + need = HeimdallAuthService.LinkableProviders; + break; + } + } + + var inGrace = merge.State is AccountMergeState.Committed or AccountMergeState.UndoRequested && merge.GraceEndsUtc > now; + return new MergeRow( + merge.Id, + MergeWording.State(merge.State), + merge.State == AccountMergeState.Failed ? MergeWording.Failure(merge.FailureCode) : null, + merge.State is AccountMergeState.Committed or AccountMergeState.Failed or AccountMergeState.Confirmed or AccountMergeState.HeimdallMoved + or AccountMergeState.UndoRequested or AccountMergeState.HeimdallReversed + ? MergeWording.Outcome(merge.LastOutcome) + : null, + merge.ConfirmedUtc is { } confirmed ? MergeWording.Date(confirmed) : null, + merge.CommittedUtc is { } committed ? MergeWording.Date(committed) : null, + merge.GraceEndsUtc is { } graceEnds ? MergeWording.Date(graceEnds) : null, + merge.FinalizedUtc is { } final ? MergeWording.Date(final) : null, + inGrace && merge.GraceEndsUtc is { } until ? MergeWording.DaysLeft(MergeWording.DaysUntil(until, now)) : null, + inGrace && merge.GraceEndsUtc is { } by ? MergeWording.TierNote(merge.ProofFromTier, by) : null, + merge.SurvivorProofProvider.Length > 0 ? [ProviderName(merge.SurvivorProofProvider)] : [], + [.. merge.FoldedProofProviders.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(ProviderName)], + canUndo, + ready, + need); + } + + private static IReadOnlyCollection MovedIds(AccountMerge merge) + { + try + { + return merge.MovedIdentityIds is { } json ? JsonSerializer.Deserialize(json) ?? [] : []; + } + catch (JsonException) + { + return []; + } + } + + private IActionResult Undone(string code) => + RedirectToPage(null, new Dictionary { [UndoParameter] = code }); + + private IActionResult Result(HeimdallLinkOutcome outcome) => + RedirectToPage(null, new Dictionary { [HeimdallLinkOutcomes.ResultParameter] = HeimdallLinkOutcomes.Code(outcome) }); +} diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 2c1ab8f..30089c5 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -24,6 +24,22 @@

@Model.StatusMessage

+ @if (Model.CanManageLogins) + { +
+ Linked accounts + See, add and remove the provider logins that sign in to this account. +
+ } + + @if (Model.HasMerges) + { +
+ Merge status + See an account merge and, for seven days, undo it. +
+ } + @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..1c78e11 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -1,5 +1,7 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using System.Security.Claims; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -12,6 +14,12 @@ public sealed class StatusModel( ICurrentBifrostActorAccessor actorAccessor, BifrostDbContext dbContext) : PageModel { + /// 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 }; + + /// Whether this account is in, or was in, a merge: the link to Merge status is shown only then. + public bool HasMerges { get; private set; } + public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public MembershipInvitation? Invitation { get; private set; } @@ -45,6 +53,9 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); + HasMerges = Actor.Merge is not null + || (Actor.UserAccount is { } account + && await dbContext.AccountMerges.AnyAsync(x => x.SurvivorUserAccountId == account.Id || x.FoldedUserAccountId == account.Id, cancellationToken)); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml b/src/Bifrost.Web/Pages/Mill/Vote.cshtml index fe75338..d9e67f2 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -28,15 +28,15 @@

-
+
-
+
@if (stance is not null) { -
+
} diff --git a/src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml b/src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml new file mode 100644 index 0000000..c9ea380 --- /dev/null +++ b/src/Bifrost.Web/Pages/Shared/Components/MergeNotice/Default.cshtml @@ -0,0 +1,5 @@ +@model string + diff --git a/src/Bifrost.Web/Pages/Shared/_Layout.cshtml b/src/Bifrost.Web/Pages/Shared/_Layout.cshtml index 34c68cb..232bc65 100644 --- a/src/Bifrost.Web/Pages/Shared/_Layout.cshtml +++ b/src/Bifrost.Web/Pages/Shared/_Layout.cshtml @@ -17,7 +17,7 @@ - + + @if (User.Identity?.IsAuthenticated == true) + { + @await Component.InvokeAsync("MergeNotice") + } +
@RenderBody()
- @await RenderSectionAsync("Scripts", required: false) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 83eb76e..800db8c 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() @@ -92,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"); }); @@ -137,6 +139,13 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHostedService(); +builder.Services.AddOptions().BindConfiguration(AccountMergeOptions.SectionName); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddOptions().BindConfiguration(ChatNoticesOptions.SectionName); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -149,9 +158,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); 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)); +// The app-secret and member-bearer clients and the mover: no redirects (see HeimdallClientServices). +builder.Services.AddHeimdallClients(); +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)); @@ -267,13 +280,15 @@ if (oidcOptions is not null) var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists(); if (deskConfigured) { - builder.Services.AddBifrostDesk(); + builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null); } 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 @@ -808,6 +823,8 @@ app.MapPost("/api/sealed-motions/{id:guid}/mind", async ( : await SealedCall(async () => await service.HandleAsync(id, envelope, ct)); }).AllowAnonymous(); +MergeNoticeEndpoints.Map(app); + app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => { httpContext.Response.Headers.CacheControl = "no-store"; @@ -822,11 +839,53 @@ 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); + 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(), + // A folded or frozen account links nothing: the actor accessor's standing decides, not the raw claim. + (await httpContext.RequestServices.GetRequiredService().GetAsync(cancellationToken)).Merge is null + ? httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) + : null, + cancellationToken); + if (step.Next is not null) + { + httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options()); + 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, step.ReturnPath)); + } + var signIn = await heimdallAuthService.CompleteAsync( hostOptions.Value.PublicBaseUrl, attempt, @@ -845,7 +904,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, heimdallAuthService.MergeReturnPathOf(held?.Value, attempt))) + : Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); diff --git a/src/Bifrost.Web/ViewComponents/MergeNotice.cs b/src/Bifrost.Web/ViewComponents/MergeNotice.cs new file mode 100644 index 0000000..5a3c779 --- /dev/null +++ b/src/Bifrost.Web/ViewComponents/MergeNotice.cs @@ -0,0 +1,57 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.ViewComponents; + +/// +/// The layout's merge notice for a signed-in actor: the survivor of a Committed or UndoRequested merge inside its grace (days left), a +/// frozen account (a merge is in progress) or a folded one (where to sign in). It reads the merge row once per page view and keeps no +/// state of its own; the words are 's, and no account name or id is ever in them. +/// +public sealed class MergeNotice(ICurrentBifrostActorAccessor actors, BifrostDbContext dbContext, TimeProvider timeProvider) : ViewComponent +{ + public async Task InvokeAsync() + { + var actor = await actors.GetAsync(HttpContext.RequestAborted); + var text = await TextAsync(actor, HttpContext.RequestAborted); + return text is null ? Content(string.Empty) : View("Default", text); + } + + private async Task TextAsync(CurrentBifrostActor actor, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + if (actor.Merge is { } standing) + { + if (standing is MergeStanding.Frozen) + { + return MergeWording.FrozenNotice(); + } + + var mergedOn = await dbContext.AccountMerges.AsNoTracking() + .Where(x => x.Id == standing.MergeId) + .Select(x => x.CommittedUtc ?? x.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + return MergeWording.FoldedNotice(mergedOn); + } + + if (actor.UserAccount is not { } account) + { + return null; + } + + var open = await dbContext.AccountMerges.AsNoTracking() + .Where(x => x.SurvivorUserAccountId == account.Id + && (x.State == AccountMergeState.Committed || x.State == AccountMergeState.UndoRequested) + && x.GraceEndsUtc > now) + .OrderBy(x => x.GraceEndsUtc) + .Select(x => new { x.State, x.GraceEndsUtc }) + .FirstOrDefaultAsync(cancellationToken); + return open is null ? null + : open.State == AccountMergeState.UndoRequested ? MergeWording.UndoRequestedNotice() + : MergeWording.SurvivorNotice(MergeWording.DaysUntil(open.GraceEndsUtc!.Value, now)); + } +} 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/src/Bifrost.Web/packages.lock.json b/src/Bifrost.Web/packages.lock.json index bd15130..796baa1 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -8,6 +8,30 @@ "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, )", + "resolved": "3.1.12", + "contentHash": "m1QEsd59gUj82ji5tEQbZiyjV6y0F7cYfGVhil6pAlNZpyzR7ze9TucMu4lPpi8cZAV8aNl3D9/T9J1HbFz15Q==", + "dependencies": { + "MessagePack.Annotations": "3.1.12", + "MessagePackAnalyzer": "3.1.12", + "Microsoft.NET.StringTools": "17.11.4" + } + }, "Microsoft.EntityFrameworkCore.Design": { "type": "Direct", "requested": "[8.0.0, )", @@ -75,17 +99,59 @@ "OpenIddict.Server": "7.7.1" } }, + "OpenIddict.Validation.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==", + "dependencies": { + "OpenIddict.Validation": "7.7.1" + } + }, + "OpenIddict.Validation.ServerIntegration": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==", + "dependencies": { + "OpenIddict.Server": "7.7.1", + "OpenIddict.Validation": "7.7.1" + } + }, "System.Text.Json": { "type": "Direct", "requested": "[8.0.5, )", "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", + "contentHash": "LdI67sY7F3KK+JsrG02+DP1XFkowaZSoyfL2M88hjm1MOOsjf8amBLAJolV+zxoPUZf3uGbKzq1cMQD8PlpSpA==" + }, + "MessagePackAnalyzer": { + "type": "Transitive", + "resolved": "3.1.12", + "contentHash": "iNSNatPh6bafRUbkQKlClExjpPpd8hiZgfZsodGf7bheIlkizGbMzsJ2mPui8jOHRe4Ky9eg5ou/fFzI7CT3fg==" + }, "Microsoft.Bcl.AsyncInterfaces": { "type": "Transitive", "resolved": "6.0.0", @@ -99,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", @@ -186,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", @@ -216,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", @@ -269,6 +403,14 @@ "Microsoft.IdentityModel.Abstractions": "8.19.2" } }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "sGxSsSrZXNmca6D+jHH2rVRyo2nNRd/g4H9CFbPmLLq0xgoH1U0orLWE5minfijw7+zq49tBs7txenbfAErRoQ==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, "Microsoft.IdentityModel.Tokens": { "type": "Transitive", "resolved": "8.19.2", @@ -279,6 +421,11 @@ "Microsoft.IdentityModel.Logging": "8.19.2" } }, + "Microsoft.NET.StringTools": { + "type": "Transitive", + "resolved": "17.11.4", + "contentHash": "mudqUHhNpeqIdJoUx2YDWZO/I9uEDYVowan89R6wsomfnUJQk6HteoQTlNjZDixhT2B4IXMkMtgZtoceIjLRmA==" + }, "Mono.TextTemplating": { "type": "Transitive", "resolved": "2.2.1", @@ -287,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", @@ -323,6 +478,17 @@ "OpenIddict.Abstractions": "7.7.1" } }, + "OpenIddict.Validation": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "09FOsvek+iSKrNbEehH2c1uBWSFurZk1U8vyTKk5aiaCV2bbj+j/68KRA/iHOgSe8qV3+0ZrquhmjFMpcojVSw==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "Microsoft.IdentityModel.Protocols": "8.19.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, "System.CodeDom": { "type": "Transitive", "resolved": "4.4.0", @@ -384,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/AccountMergeHttpTests.cs b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs new file mode 100644 index 0000000..e148132 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeHttpTests.cs @@ -0,0 +1,439 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +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 folded member's browser and Heimdall's patron intake, over HTTP: the merge ledger is the single gate behind both. +public sealed class AccountMergeHttpTests(TestWebApplicationFactory factory) : IClassFixture +{ + private sealed record Seeded(Guid Survivor, Guid Folded, Guid Motion, string FoldedHeimdallId, long SurvivorGitHubId, long FoldedGitHubId); + + private async Task SeedAsync() + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var seed = Random.Shared.NextInt64(1_000_000, 900_000_000); + UserAccount Make(string name, long gitHubId) => new() + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = name, GitHubUserId = gitHubId, GitHubLogin = name, NormalizedGitHubLogin = name, + HeimdallAccountId = "hd-" + name, + MemberProfile = new MemberProfile { Nickname = name, Headline = "seed", UpdatedAtUtc = DateTimeOffset.UtcNow }, + Membership = new Bifrost.Web.Domain.Membership { Status = MembershipStatus.Active, ApprovedAtUtc = DateTimeOffset.UtcNow } + }; + var survivor = Make($"surv-{seed}", seed); + var folded = Make($"fold-{seed}", seed + 1); + db.UserAccounts.AddRange(survivor, folded); + var motion = new Motion + { + CreatedByUserAccountId = survivor.Id, Title = "Open motion", Status = MotionStatus.Open, OpensAtUtc = DateTimeOffset.UtcNow.AddHours(-1), + ClosesAtUtc = DateTimeOffset.UtcNow.AddDays(2), CreatedAtUtc = DateTimeOffset.UtcNow + }; + db.Motions.Add(motion); + await db.SaveChangesAsync(); + return new Seeded(survivor.Id, folded.Id, motion.Id, folded.HeimdallAccountId, seed, seed + 1); + } + + private HttpClient ClientFor(long gitHubId, string login) + { + var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost"), HandleCookies = true }); + client.DefaultRequestHeaders.Add("X-Test-GitHub-Id", gitHubId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-GitHub-Login", login); + return client; + } + + private static async Task TokenAsync(HttpClient client) + { + using var page = await client.GetAsync("/App/Motions"); + var html = await page.Content.ReadAsStringAsync(); + var token = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\"").Groups[1].Value; + Assert.NotEmpty(token); + return token; + } + + private static Task VoteAsync(HttpClient client, Guid motion, string token) => + client.PostAsync("/App/Motions?handler=Vote", new FormUrlEncodedContent(new Dictionary + { + ["motionId"] = motion.ToString(), ["choice"] = "For", ["comment"] = "yes", ["__RequestVerificationToken"] = token + })); + + private async Task WithDb(Func> read) + { + using var scope = factory.Services.CreateScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } + + private Task VoteRowsAsync(Guid motion) => WithDb(db => db.Votes.IgnoreQueryFilters().CountAsync(x => x.MotionId == motion)); + + private async Task MergeAsync(Seeded s, AccountMergeState state) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var merge = new AccountMerge { SurvivorUserAccountId = s.Survivor, FoldedUserAccountId = s.Folded, State = state, CreatedUtc = DateTimeOffset.UtcNow }; + db.AccountMerges.Add(merge); + await db.SaveChangesAsync(); + return merge.Id; + } + + private async Task SetStateAsync(Guid mergeId, AccountMergeState state) => + await WithDb(async db => + { + (await db.AccountMerges.SingleAsync(x => x.Id == mergeId)).State = state; + await db.SaveChangesAsync(); + return 0; + }); + + [Fact] + public async Task A_folded_session_cookie_acts_as_nobody_while_Frozen_and_after_Committed_and_the_survivor_still_votes() + { + var s = await SeedAsync(); + using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}"); + var token = await TokenAsync(folded); // a live, valid form from before the merge: the cookie outlives the merge + var merge = await MergeAsync(s, AccountMergeState.Confirmed); + + using (var whileFrozen = await VoteAsync(folded, s.Motion, token)) + { + Assert.NotEqual(HttpStatusCode.OK, whileFrozen.StatusCode); + } + + Assert.Equal(0, await VoteRowsAsync(s.Motion)); + using (var page = await folded.GetAsync("/App/Motions")) + { + Assert.NotEqual(HttpStatusCode.OK, page.StatusCode); + } + + await SetStateAsync(merge, AccountMergeState.HeimdallMoved); + await SetStateAsync(merge, AccountMergeState.Committed); + using (var afterCommit = await VoteAsync(folded, s.Motion, token)) + { + Assert.NotEqual(HttpStatusCode.OK, afterCommit.StatusCode); + } + + Assert.Equal(0, await VoteRowsAsync(s.Motion)); + + using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}"); + using var control = await VoteAsync(survivor, s.Motion, await TokenAsync(survivor)); + Assert.Equal(HttpStatusCode.Redirect, control.StatusCode); + Assert.Equal(1, await VoteRowsAsync(s.Motion)); + } + + private async Task IntakeAsync(Seeded s, string eventId) + { + var payload = JsonSerializer.Serialize(new + { + heimdallAccountId = s.FoldedHeimdallId, provider = "PayPal", providerEventId = eventId, kind = "OneTimeDonation", + amount = 25m, currencyCode = "USD", externalSupportId = "CAPTURE-1", supportedAtUtc = DateTimeOffset.UtcNow, isCurrentRecurringSupport = false + }); + using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("test-heimdall-intake-secret")); + using var request = new HttpRequestMessage(HttpMethod.Post, "/heimdall/patron-support/events"); + request.Headers.Add("X-Heimdall-Signature-256", "sha256=" + Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant()); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var client = factory.CreateClient(); + using var response = await client.SendAsync(request); + return response.StatusCode; + } + + private Task> EventOwnersAsync(Seeded s) => + WithDb(db => db.PatronSupportEvents.Where(x => x.UserAccountId == s.Survivor || x.UserAccountId == s.Folded).Select(x => x.UserAccountId).ToListAsync()); + + /// Lands the merge through the ledger's own commit, as the saga will: the move done, then the Bifrost records reduced. + private async Task CommitMergeAsync(Seeded s) + { + var merge = await MergeAsync(s, AccountMergeState.HeimdallMoved); + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().CommitAsync(merge, default); + return 0; + }); + return merge; + } + + [Fact] + public async Task Patron_intake_for_a_folded_heimdall_id_records_on_the_survivor() + { + var s = await SeedAsync(); + await MergeAsync(s, AccountMergeState.Final); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}")); + + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_patron_event_that_lands_during_grace_is_journalled_once_and_undo_moves_it_back_to_the_folded_account() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var eventId = $"WH-{Guid.NewGuid():N}"; + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); // the provider retries: nothing doubles + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + Assert.Equal(1, await WithDb(db => db.AccountMergeEntries.CountAsync(x => x.MergeId == merge && x.Kind == AccountMergeEntryKind.PatronSupportEvent))); + + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().UndoLocalAsync(merge, default); + return 0; + }); + + Assert.Equal([s.Folded], await EventOwnersAsync(s)); + } + + private Task> PatronJournalAsync(Guid merge) => + WithDb(db => db.AccountMergeEntries.Where(x => x.MergeId == merge).OrderBy(x => x.Seq).ToListAsync()); + + [Fact] + public async Task A_single_patron_event_during_grace_is_journalled_by_its_own_delivery_and_undo_moves_it_back() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, $"WH-{Guid.NewGuid():N}")); // no retry follows + + var journal = await PatronJournalAsync(merge); + Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); // the new entry takes its own Seq + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().UndoLocalAsync(merge, default); + return 0; + }); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_provider_retry_journals_an_event_the_first_delivery_left_unjournalled() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var eventId = $"WH-{Guid.NewGuid():N}"; + await WithDb(async db => // the first delivery recorded on the survivor, then died before journalling + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = s.Survivor, Provider = ExternalPatronProvider.PayPal, ProviderEventId = eventId, ExternalSupportId = "CAPTURE-1", + Kind = PatronSupportEventKind.OneTimeDonation, Amount = 25m, SupportedAtUtc = DateTimeOffset.UtcNow, RecordedAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return 0; + }); + Assert.DoesNotContain(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); + + Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_stripe_checkout_opened_by_an_account_that_folded_pays_the_survivor_and_is_journalled() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + using var response = await StripeCheckoutAsync(s); + + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(s.Folded, entry.PriorUserAccountId); // the journal names the folded account as the previous owner + + await SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await UndoAsync(merge); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); // so an undo sends the donation back where it was opened + } + + private async Task StripeCheckoutAsync(Seeded s) + { + var payload = JsonSerializer.Serialize(new + { + id = $"evt_{Guid.NewGuid():N}", type = "checkout.session.completed", created = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + data = new { @object = new { id = $"cs_{Guid.NewGuid():N}", customer = "cus_1", currency = "usd", amount_total = 1900, payment_status = "paid", + metadata = new Dictionary { ["bifrost_user_account_id"] = s.Folded.ToString("N") } } } + }); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); + using var hasher = new HMACSHA256(Encoding.UTF8.GetBytes("whsec_test_webhook_secret")); + using var request = new HttpRequestMessage(HttpMethod.Post, "/patronage/stripe/webhook"); + request.Headers.Add("Stripe-Signature", $"t={timestamp},v1={Convert.ToHexString(hasher.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{payload}"))).ToLowerInvariant()}"); + request.Content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var client = factory.CreateClient(); + + return await client.SendAsync(request); + } + + private Task UndoAsync(Guid merge) => + WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().UndoLocalAsync(merge, default); + return 0; + }); + + [Fact] + public async Task A_stripe_checkout_opened_by_an_account_that_folded_into_a_suspended_survivor_is_refused_as_not_configured_and_records_nothing() + { + var s = await SeedAsync(); + await CommitMergeAsync(s); + await WithDb(async db => + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == s.Survivor)).Status = MembershipStatus.Suspended; + await db.SaveChangesAsync(); + return 0; + }); + + using var response = await StripeCheckoutAsync(s); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + Assert.Empty(await EventOwnersAsync(s)); + } + + [Fact] + public async Task The_admin_ledgers_manual_patron_record_for_a_folded_account_lands_on_the_survivor_and_is_journalled() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + + await WithDb(async db => + { + using var scope = factory.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().RecordSupportEventAsync( + s.Survivor, s.Folded, PatronSupportEventKind.OneTimeDonation, 10m, "USD", "manual-1", ExternalPatronProvider.Manual, + string.Empty, string.Empty, string.Empty, DateTimeOffset.UtcNow, false, string.Empty, default); // as Pages/App/Ledger records it + return 0; + }); + + Assert.Equal([s.Survivor], await EventOwnersAsync(s)); + var entry = Assert.Single(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal(s.Folded, entry.PriorUserAccountId); + } + + [Fact] + public async Task A_provider_retry_of_an_event_stranded_on_the_folded_account_journals_nothing_because_nothing_moved() + { + var s = await SeedAsync(); + var merge = await CommitMergeAsync(s); + var eventId = $"WH-{Guid.NewGuid():N}"; + await WithDb(async db => // recorded on the folded account by a delivery that straddled the commit + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = s.Folded, Provider = ExternalPatronProvider.PayPal, ProviderEventId = eventId, ExternalSupportId = "CAPTURE-1", + Kind = PatronSupportEventKind.OneTimeDonation, Amount = 25m, SupportedAtUtc = DateTimeOffset.UtcNow, RecordedAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return 0; + }); + + Assert.Equal(HttpStatusCode.Accepted, await IntakeAsync(s, eventId)); + + Assert.DoesNotContain(await PatronJournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + Assert.Equal([s.Folded], await EventOwnersAsync(s)); + } + + [Fact] + public async Task A_folded_bootstrap_admins_sign_in_does_not_reactivate_the_merged_membership_and_an_approval_is_refused() + { + var s = await SeedAsync(); + await CommitMergeAsync(s); + using var folded = ClientFor(s.FoldedGitHubId, "test-admin"); // the bootstrap login: every request would re-promote it + + using (await folded.GetAsync("/")) + { + } + + Assert.Equal((MembershipStatus.Merged, false), await WithDb(async db => + { + var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded); + return (membership.Status, membership.IsPlatformAdmin); + })); + + using var admin = ClientFor(Random.Shared.NextInt64(1_000_000_000, 2_000_000_000), "test-admin"); + var token = await TokenAsync(admin); + using var approve = await admin.PostAsync("/App/Members?handler=Approve", new FormUrlEncodedContent(new Dictionary + { + ["userAccountId"] = s.Folded.ToString(), ["__RequestVerificationToken"] = token + })); + Assert.Equal(HttpStatusCode.Redirect, approve.StatusCode); + Assert.Equal(MembershipStatus.Merged, await WithDb(async db => (await db.Memberships.SingleAsync(x => x.UserAccountId == s.Folded)).Status)); + } + + /// Forbid answers 403 or sends the browser to the access-denied page; either way the page and its Heimdall flows are not served. + private static void AssertRefused(HttpResponseMessage response) + { + Assert.True(response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Found, response.StatusCode.ToString()); + Assert.DoesNotContain("heimdall", response.Headers.Location?.ToString() ?? "", StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed)] + public async Task A_frozen_or_folded_cookie_cannot_reach_the_linked_accounts_page_or_start_one_of_its_flows(AccountMergeState state) + { + var s = await SeedAsync(); + using var folded = ClientFor(s.FoldedGitHubId, $"fold-{s.SurvivorGitHubId}"); + var token = await TokenAsync(folded); + using (var control = await folded.GetAsync("/Membership/LinkedAccounts")) + { + Assert.Equal(HttpStatusCode.OK, control.StatusCode); // before the merge the page is theirs + } + + await MergeAsync(s, state); + + using var page = await folded.GetAsync("/Membership/LinkedAccounts"); + AssertRefused(page); + using var refresh = await folded.PostAsync("/Membership/LinkedAccounts?handler=Refresh", new FormUrlEncodedContent(new Dictionary + { + ["via"] = "google", ["__RequestVerificationToken"] = token + })); + AssertRefused(refresh); + + using var survivor = ClientFor(s.SurvivorGitHubId, $"surv-{s.SurvivorGitHubId}"); + using var other = await survivor.GetAsync("/Membership/LinkedAccounts"); + Assert.Equal(HttpStatusCode.OK, other.StatusCode); + } +} + +/// The desk's bearer door judges standing the way the cookie gate and the chat refusal do. +public sealed class AccountMergeDeskTests +{ + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed)] + public async Task A_frozen_or_folded_subjects_live_desk_token_is_forbidden(AccountMergeState state) + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"); + Task ListAsync() => factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token); + + Assert.Equal(200, (await ListAsync()).Status); // the live member, before the merge + + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var survivor = new UserAccount { BifrostIdentity = "desk-survivor", NormalizedBifrostIdentity = "desk-survivor", GitHubLogin = "desk-survivor", NormalizedGitHubLogin = "desk-survivor" }; + db.UserAccounts.Add(survivor); + db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = Guid.Parse(DeskWebApplicationFactory.Alice), State = state, CreatedUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + Assert.Equal(403, (await ListAsync()).Status); + } +} diff --git a/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs b/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs new file mode 100644 index 0000000..3185be5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeLedgerTests.cs @@ -0,0 +1,1121 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Patronage; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// The merge ledger from its consumers' side: the motion state and close a member sees, the concept tallies a visitor sees, the actor +/// a sign-in resolves to, the patron weight, and the undo. Each operation opens its own context, as a request scope would. +/// +public sealed class AccountMergeLedgerTests +{ + public static readonly DateTimeOffset T0 = new(2026, 11, 14, 12, 0, 0, TimeSpan.Zero); + + public sealed class Clock(DateTimeOffset start) : TimeProvider + { + public DateTimeOffset Now { get; set; } = start; + + public override DateTimeOffset GetUtcNow() => Now; + } + + /// The merge fixtures. In memory by default; points the same fixtures at a real database, where unique indexes, transactions, tokens and triggers exist. + public sealed class World + { + private readonly string _name = $"merge-{Guid.NewGuid():N}"; + private readonly SaveChangesInterceptor[] _interceptors; + private readonly string? _postgres; + + public World(params SaveChangesInterceptor[] interceptors) => _interceptors = interceptors; + + private World(string postgres, SaveChangesInterceptor[] interceptors) : this(interceptors) => _postgres = postgres; + + public static World OnPostgres(string connectionString, params SaveChangesInterceptor[] interceptors) => new(connectionString, interceptors); + + public Clock Clock { get; } = new(T0); + + public BifrostDbContext Db() + { + var options = new DbContextOptionsBuilder().AddInterceptors(_interceptors); + if (_postgres is null) + { + options.UseInMemoryDatabase(_name).ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning)); + } + else + { + options.UseNpgsql(_postgres); + } + + return new BifrostDbContext(options.Options); + } + + public AccountMergeLedger Ledger(BifrostDbContext db) => new(db, new PatronageService(db, new AuditTrailService(db, Clock), Clock), Clock); + + public MotionGovernanceService Motions(BifrostDbContext db) => new(db, new AuditTrailService(db, Clock), Clock); + + public ConceptAppraisalService Concepts(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock, Options.Create(new ConceptVoteOptions())); + + public async Task AccountAsync(string name, MembershipStatus status = MembershipStatus.Active, bool heimdall = true, bool moderator = false, params MemberRole[] roles) + { + await using var db = Db(); + var account = new UserAccount + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = BifrostIdentityService.NormalizeIdentity(name), + HeimdallAccountId = heimdall ? $"hd-{name}-{Guid.NewGuid():N}" : string.Empty, + CreatedAtUtc = T0, LastSeenAtUtc = T0 + }; + account.Membership = new Membership { UserAccountId = account.Id, Status = status, CanModerateMotions = moderator }; + foreach (var role in roles) + { + account.Membership.RoleAssignments.Add(new RoleAssignment { Role = role }); + } + + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return account; + } + + public async Task MotionAsync(string title = "m", MotionStatus status = MotionStatus.Open, MotionKind kind = MotionKind.Standard) + { + await using var db = Db(); + var author = await AccountAsync("author-" + Guid.NewGuid().ToString("N")[..6]); + var motion = new Motion + { + CreatedByUserAccountId = author.Id, Title = title, Status = status, Kind = kind, OpensAtUtc = T0, + ClosesAtUtc = T0.AddDays(3), CreatedAtUtc = T0, ApprovalThreshold = 0.5m + }; + db.Motions.Add(motion); + await db.SaveChangesAsync(); + return motion; + } + + public async Task VoteAsync(Motion motion, UserAccount who, VoteChoice choice, decimal weight = 1m, DateTimeOffset? at = null) + { + await using var db = Db(); + db.Votes.Add(new Vote { MotionId = motion.Id, UserAccountId = who.Id, Choice = choice, Weight = weight, CastAtUtc = at ?? T0.AddHours(1) }); + await db.SaveChangesAsync(); + } + + public async Task MergeAsync(UserAccount survivor, UserAccount folded, AccountMergeState state = AccountMergeState.HeimdallMoved) + { + await using var db = Db(); + var merge = new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = state, CreatedUtc = T0 }; + db.AccountMerges.Add(merge); + await db.SaveChangesAsync(); + return merge; + } + + public async Task CommitAsync(AccountMerge merge) + { + await using var db = Db(); + return await Ledger(db).CommitAsync(merge.Id, default); + } + + public async Task SetStateAsync(AccountMerge merge, AccountMergeState state) + { + await using var db = Db(); + (await db.AccountMerges.SingleAsync(x => x.Id == merge.Id)).State = state; + await db.SaveChangesAsync(); + } + + public async Task StateOfAsync(Motion motion, UserAccount viewer) + { + await using var db = Db(); + var actorAccount = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .SingleAsync(x => x.Id == viewer.Id); + var state = await Motions(db).GetStateAsync(new CurrentBifrostActor(true, actorAccount), default); + return state.Motions.Single(x => x.Id == motion.Id); + } + + public async Task ActorAsync(Guid accountId) + { + await using var db = Db(); + var account = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots).Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .SingleAsync(x => x.Id == accountId); + return new CurrentBifrostActor(true, account); + } + + public async Task CloseAsync(Motion motion, UserAccount moderator) + { + await using var db = Db(); + await Motions(db).CloseMotionAsync(await ActorAsync(moderator.Id), motion.Id, default); + await using var check = Db(); + return (await check.Motions.SingleAsync(x => x.Id == motion.Id)).Status; + } + + public async Task> AllVotesAsync(Motion motion) + { + await using var db = Db(); + return await db.Votes.IgnoreQueryFilters().AsNoTracking().Where(x => x.MotionId == motion.Id).ToListAsync(); + } + + public async Task> JournalAsync(AccountMerge merge) + { + await using var db = Db(); + return await db.AccountMergeEntries.AsNoTracking().Where(x => x.MergeId == merge.Id).OrderBy(x => x.Seq).ToListAsync(); + } + + public async Task MembershipOfAsync(UserAccount account) + { + await using var db = Db(); + return await db.Memberships.AsNoTracking().Include(x => x.TierSnapshots).Include(x => x.RoleAssignments).SingleAsync(x => x.UserAccountId == account.Id); + } + } + + // ---- votes ---- + + [Fact] + public async Task Both_voted_on_an_open_motion_only_the_survivors_weight_counts_in_state_and_close() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.Against, 1m); + await world.VoteAsync(motion, folded, VoteChoice.For, 3m); + var before = await world.StateOfAsync(motion, moderator); + Assert.Equal((3m, 1m), (before.VotesFor, before.VotesAgainst)); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var after = await world.StateOfAsync(motion, moderator); + Assert.Equal((0m, 1m), (after.VotesFor, after.VotesAgainst)); + Assert.Equal(MotionStatus.Failed, await world.CloseAsync(motion, moderator)); + Assert.Equal(2, (await world.AllVotesAsync(motion)).Count); // nothing is deleted + } + + [Fact] + public async Task A_vote_only_the_folded_account_cast_moves_and_a_later_survivor_cast_replaces_it_never_doubling() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 1m); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(1m, (await world.StateOfAsync(motion, moderator)).VotesFor); + await using (var db = world.Db()) + { + await world.Motions(db).CastVoteAsync(await world.ActorAsync(survivor.Id), motion.Id, VoteChoice.Against, "changed my mind", default); + } + + var item = await world.StateOfAsync(motion, moderator); + Assert.Equal((0m, 1m), (item.VotesFor, item.VotesAgainst)); + var row = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal(survivor.Id, row.UserAccountId); + Assert.Null(row.VoidedByMergeId); + } + + [Fact] + public async Task A_vote_only_the_folded_account_cast_is_voided_when_the_survivor_is_not_active() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor", MembershipStatus.PendingApproval); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 1m); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(0m, (await world.StateOfAsync(motion, moderator)).VotesFor); + var row = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal(merge.Id, row.VoidedByMergeId); + Assert.Equal(folded.Id, row.UserAccountId); + } + + [Fact] + public async Task Closed_motions_keep_every_vote_and_tally() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(status: MotionStatus.Passed); + await world.VoteAsync(motion, survivor, VoteChoice.For, 1m); + await world.VoteAsync(motion, folded, VoteChoice.For, 2m); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(3m, (await world.StateOfAsync(motion, moderator)).VotesFor); + Assert.All(await world.AllVotesAsync(motion), v => Assert.Null(v.VoidedByMergeId)); + } + + // ---- concept appraisals ---- + + [Fact] + public async Task Appraisals_reduce_to_one_voice_per_person_and_the_tallies_count_the_person_once() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + Guid both, onlyFolded; + await using (var db = world.Db()) + { + var s1 = new ConceptSubject { Collection = "c", Key = "both", Title = "Both", RegisteredAtUtc = T0 }; + var s2 = new ConceptSubject { Collection = "c", Key = "only-folded", Title = "Only folded", RegisteredAtUtc = T0 }; + db.ConceptSubjects.AddRange(s1, s2); + ConceptAppraisal Row(ConceptSubject s, UserAccount who, ConceptStance stance) => new() + { + SubjectId = s.Id, UserAccountId = who.Id, VoterKey = "account:" + who.Id.ToString("N"), Stance = stance, + VoterClass = VoterClass.Member, CastAtUtc = T0 + }; + db.ConceptAppraisals.AddRange(Row(s1, survivor, ConceptStance.Up), Row(s1, folded, ConceptStance.Down), Row(s2, folded, ConceptStance.Down)); + await db.SaveChangesAsync(); + (both, onlyFolded) = (s1.Id, s2.Id); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using var read = world.Db(); + var tallies = await world.Concepts(read).GetTalliesAsync("c", default); + Assert.Equal((1, 0), (tallies["both"].Up, tallies["both"].Down)); + Assert.Equal((0, 1), (tallies["only-folded"].Up, tallies["only-folded"].Down)); + var moved = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal(survivor.Id, moved.UserAccountId); + Assert.Equal("account:" + survivor.Id.ToString("N"), moved.VoterKey); + Assert.Equal(1, await read.ConceptAppraisals.IgnoreQueryFilters().CountAsync(x => x.SubjectId == both && x.VoidedByMergeId != null)); + } + + // ---- membership, standing, patronage ---- + + [Fact] + public async Task The_folded_membership_becomes_Merged_with_weight_zero_and_no_role_or_admin_flag_is_carried() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor", roles: MemberRole.StandardMember); + var folded = await world.AccountAsync("folded", roles: [MemberRole.PlatformAdmin, MemberRole.Producer]); + await using (var db = world.Db()) + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).IsPlatformAdmin = true; + await db.SaveChangesAsync(); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var foldedMembership = await world.MembershipOfAsync(folded); + Assert.Equal(MembershipStatus.Merged, foldedMembership.Status); + Assert.Equal(0m, foldedMembership.EffectiveVotingWeight); + var survivorMembership = await world.MembershipOfAsync(survivor); + Assert.Equal([MemberRole.StandardMember], survivorMembership.RoleAssignments.Select(x => x.Role)); + Assert.False(survivorMembership.IsPlatformAdmin); + Assert.Equal(MembershipStatus.Active, survivorMembership.Status); + } + + [Fact] + public async Task A_suspended_folded_account_suspends_the_survivor_and_undo_restores_it_only_if_unchanged() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded", MembershipStatus.Suspended); + var untouched = await world.MergeAsync(survivor, folded); + await world.CommitAsync(untouched); + + var suspended = await world.MembershipOfAsync(survivor); + Assert.Equal(MembershipStatus.Suspended, suspended.Status); + Assert.Equal(AccountMergeLedger.SuspensionReason(untouched.Id), suspended.SuspensionReason); + + await world.SetStateAsync(untouched, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(untouched.Id, default); + } + + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(survivor)).Status); + Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(folded)).Status); + + // The same again, but a steward re-suspends the survivor for their own reason before the undo: that standing is theirs now. + var folded2 = await world.AccountAsync("folded2", MembershipStatus.Suspended); + var second = await world.MergeAsync(survivor, folded2); + await world.CommitAsync(second); + await using (var db = world.Db()) + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)).SuspensionReason = "steward decision"; + await db.SaveChangesAsync(); + } + + await world.SetStateAsync(second, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(second.Id, default); + } + + Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(survivor)).Status); + Assert.Contains(await world.JournalAsync(second), e => e.Kind == AccountMergeEntryKind.Standing && e.Action == AccountMergeEntryAction.KeptAfterUndo); + } + + [Fact] + public async Task Patron_events_follow_the_person_and_the_survivors_tier_derives_once_from_all_events_not_tier_plus_tier() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await using (var db = world.Db()) + { + PatronSupportEvent Event(UserAccount who, decimal amount) => new() + { + UserAccountId = who.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = amount, + ProviderEventId = Guid.NewGuid().ToString("N"), SupportedAtUtc = T0, RecordedAtUtc = T0 + }; + db.PatronSupportEvents.AddRange(Event(survivor, 90m), Event(folded, 1000m)); + var memberships = await db.Memberships.ToListAsync(); + foreach (var (who, weight) in new[] { (survivor, 1m), (folded, 3m) }) + { + db.TierSnapshots.Add(new TierSnapshot { MembershipId = memberships.Single(m => m.UserAccountId == who.Id).Id, Kind = TierSnapshotKind.Patron, Label = "t", Weight = weight, IsCurrent = true, EffectiveFromUtc = T0, CapturedAtUtc = T0 }); + } + + await db.SaveChangesAsync(); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var survivorMembership = await world.MembershipOfAsync(survivor); + Assert.Equal(3m, survivorMembership.EffectiveVotingWeight); // 1090 points: Gold, not Bronze 1 + Gold 3 + Assert.Single(survivorMembership.TierSnapshots, x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent); + Assert.DoesNotContain(( await world.MembershipOfAsync(folded)).TierSnapshots, x => x.IsCurrent); + await using var db2 = world.Db(); + Assert.All(await db2.PatronSupportEvents.ToListAsync(), e => Assert.Equal(survivor.Id, e.UserAccountId)); + } + + // ---- who the actor is ---- + + [Fact] + public async Task A_principal_whose_identity_names_one_row_and_whose_heimdall_id_names_another_resolves_the_first_and_does_not_throw() + { + var world = new World(); + var byIdentity = await world.AccountAsync("by-identity"); + var byHeimdall = await world.AccountAsync("by-heimdall"); + var http = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + http.User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity( + [ + new System.Security.Claims.Claim(BifrostClaimTypes.BifrostIdentity, byIdentity.BifrostIdentity), + new System.Security.Claims.Claim(BifrostClaimTypes.HeimdallAccountId, byHeimdall.HeimdallAccountId) + ], "test")); + await using var db = world.Db(); + var accessor = new CurrentBifrostActorAccessor(new Microsoft.AspNetCore.Http.HttpContextAccessor { HttpContext = http }, db, world.Ledger(db)); + + var actor = await accessor.GetAsync(default); + + Assert.Equal(byIdentity.Id, actor.UserAccount!.Id); + } + + [Theory] + [InlineData(AccountMergeState.Confirmed, true)] + [InlineData(AccountMergeState.HeimdallMoved, true)] + [InlineData(AccountMergeState.Committed, false)] + [InlineData(AccountMergeState.Final, false)] + [InlineData(AccountMergeState.UndoRequested, false)] + [InlineData(AccountMergeState.HeimdallReversed, false)] + public async Task A_folded_account_is_frozen_then_folded_and_acts_as_nobody_while_the_survivor_acts_normally(AccountMergeState state, bool frozen) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded", roles: MemberRole.PlatformAdmin); + var merge = await world.MergeAsync(survivor, folded, state); + + foreach (var (account, isFolded) in new[] { (folded, true), (survivor, false) }) + { + var http = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + http.User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity( + [new System.Security.Claims.Claim(BifrostClaimTypes.HeimdallAccountId, account.HeimdallAccountId)], "test")); + await using var db = world.Db(); + var actor = await new CurrentBifrostActorAccessor(new Microsoft.AspNetCore.Http.HttpContextAccessor { HttpContext = http }, db, world.Ledger(db)).GetAsync(default); + + if (!isFolded) + { + Assert.Equal(survivor.Id, actor.UserAccount!.Id); + Assert.Null(actor.Merge); + continue; + } + + Assert.True(actor.IsAuthenticated); + Assert.Null(actor.UserAccount); + Assert.False(actor.IsPlatformAdmin); + Assert.False(actor.IsActiveMember); + Assert.Equal(merge.Id, actor.Merge!.MergeId); + Assert.Equal(frozen, actor.Merge is MergeStanding.Frozen); + Assert.Equal(!frozen, actor.Merge is MergeStanding.Folded f && f.SurvivorId == survivor.Id); + } + } + + [Theory] + [InlineData(AccountMergeState.Proposed)] + [InlineData(AccountMergeState.Failed)] + [InlineData(AccountMergeState.Undone)] + public async Task A_merge_that_never_reached_or_has_left_the_move_leaves_the_folded_account_standing_alone(AccountMergeState state) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await world.MergeAsync(survivor, folded, state); + + await using var db = world.Db(); + Assert.Null(await world.Ledger(db).StandingAsync(folded.Id, default)); + } + + // ---- plan ---- + + [Fact] + public async Task Plan_writes_nothing_and_says_what_a_commit_would_do() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + + await using (var db = world.Db()) + { + var plan = await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default); + Assert.Empty(plan.Blocks); + Assert.Contains(plan.Entries, e => e.Kind == AccountMergeEntryKind.Vote && e.Action == AccountMergeEntryAction.Moved); + Assert.Contains(plan.Entries, e => e.Kind == AccountMergeEntryKind.Membership && e.Action == AccountMergeEntryAction.Closed); + } + + await using var check = world.Db(); + Assert.Empty(check.AccountMerges); + Assert.Empty(check.AccountMergeEntries); + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + Assert.Equal(folded.Id, Assert.Single(await world.AllVotesAsync(motion)).UserAccountId); + } + + public static IEnumerable BlockCases() => + new[] { "same_account", "mind_account", "native_account", "merge_in_flight", "survivor_is_folded", "already_folded", "folded_is_survivor", "sealed_ballot_pending" } + .Select(x => new object[] { x }); + + [Theory] + [MemberData(nameof(BlockCases))] + public async Task Plan_reports_each_block_by_code(string code) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded", heimdall: code != "native_account"); + var other = await world.AccountAsync("other"); + var (planSurvivor, planFolded) = (survivor.Id, folded.Id); + switch (code) + { + case "same_account": + planFolded = survivor.Id; + break; + case "mind_account": + await using (var db = world.Db()) + { + db.MindPersonas.Add(new MindPersona { PersonaId = "p", UserAccountId = folded.Id }); + await db.SaveChangesAsync(); + } + + break; + case "merge_in_flight": + await world.MergeAsync(other, folded, AccountMergeState.Confirmed); + break; + case "survivor_is_folded": + await world.MergeAsync(other, survivor, AccountMergeState.Final); + break; + case "already_folded": + await world.MergeAsync(other, folded, AccountMergeState.Committed); + break; + case "folded_is_survivor": + await world.MergeAsync(folded, other, AccountMergeState.Final); + break; + case "sealed_ballot_pending": + var motion = await world.MotionAsync(kind: MotionKind.SealedBallot); + await using (var db = world.Db()) + { + db.SealedBallots.Add(new SealedBallot { MotionId = motion.Id, UserAccountId = folded.Id, Commitment = "c", CommittedAtUtc = T0 }); + await db.SaveChangesAsync(); + } + + break; + } + + await using var read = world.Db(); + var plan = await world.Ledger(read).PlanAsync(planSurvivor, planFolded, default); + + Assert.Contains(code, plan.Blocks); + } + + // ---- commit, undo, finalize ---- + + [Fact] + public async Task Commit_twice_is_a_no_op_the_second_time_and_the_journal_is_unchanged() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var merge = await world.MergeAsync(survivor, folded); + + var first = await world.CommitAsync(merge); + var journal = await world.JournalAsync(merge); + world.Clock.Now = T0.AddDays(1); + var second = await world.CommitAsync(merge); + + Assert.Equal(AccountMergeState.Committed, second.State); + Assert.Equal(first.CommittedUtc, second.CommittedUtc); + Assert.Equal(first.GraceEndsUtc, T0 + TimeSpan.FromDays(7)); + Assert.Equal(journal.Count, (await world.JournalAsync(merge)).Count); + Assert.NotEmpty(journal); + } + + private sealed class FailOnce : SaveChangesInterceptor + { + public bool Armed { get; set; } + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Armed) + { + Armed = false; + throw new DbUpdateException("simulated store failure"); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + } + + [Fact] + public async Task A_commit_whose_save_fails_applies_nothing_and_a_retry_applies_exactly_once() + { + var failure = new FailOnce(); + var world = new World(failure); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var merge = await world.MergeAsync(survivor, folded); + + failure.Armed = true; + await using (var db = world.Db()) + { + var ledger = world.Ledger(db); + await Assert.ThrowsAsync(() => ledger.CommitAsync(merge.Id, default)); + await using var check = world.Db(); + Assert.Equal(AccountMergeState.HeimdallMoved, (await check.AccountMerges.SingleAsync()).State); + Assert.Empty(check.AccountMergeEntries); + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + + await ledger.CommitAsync(merge.Id, default); // the same context, retried + } + + var journal = await world.JournalAsync(merge); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); + } + + [Theory] + [InlineData(AccountMergeState.Proposed)] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Failed)] + public async Task Commit_from_any_state_but_HeimdallMoved_throws_and_undo_from_Committed_throws(AccountMergeState state) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.MergeAsync(survivor, folded, state); + + await using var db = world.Db(); + await Assert.ThrowsAsync(() => world.Ledger(db).CommitAsync(merge.Id, default)); + await world.SetStateAsync(merge, AccountMergeState.Committed); + await using var db2 = world.Db(); + await Assert.ThrowsAsync(() => world.Ledger(db2).UndoLocalAsync(merge.Id, default)); + } + + [Fact] + public async Task Undo_restores_open_motion_votes_keeps_voided_ones_on_since_closed_motions_and_a_survivor_recast_and_restores_membership_and_patron_events() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var bothOpen = await world.MotionAsync("both-open"); + var bothClosing = await world.MotionAsync("both-closing"); + var movedKept = await world.MotionAsync("moved-recast"); + var movedBack = await world.MotionAsync("moved-back"); + foreach (var motion in new[] { bothOpen, bothClosing }) + { + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + } + + await world.VoteAsync(movedKept, folded, VoteChoice.For, at: T0.AddHours(2)); + await world.VoteAsync(movedBack, folded, VoteChoice.For, at: T0.AddHours(3)); + await using (var db = world.Db()) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = folded.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 100m, + ProviderEventId = "e1", SupportedAtUtc = T0, RecordedAtUtc = T0 + }); + await db.SaveChangesAsync(); + } + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + Assert.Equal(2m, (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight); + + // After the merge: one motion closes, and the survivor re-casts on a moved vote. + await world.CloseAsync(bothClosing, moderator); + world.Clock.Now = T0.AddDays(1); + await using (var db = world.Db()) + { + await world.Motions(db).CastVoteAsync(await world.ActorAsync(survivor.Id), movedKept.Id, VoteChoice.Against, null, default); + } + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + Assert.Equal(AccountMergeState.Undone, (await world.Ledger(db).UndoLocalAsync(merge.Id, default)).State); + } + + var open = (await world.AllVotesAsync(bothOpen)).Single(v => v.UserAccountId == folded.Id); + Assert.Null(open.VoidedByMergeId); + var closed = (await world.AllVotesAsync(bothClosing)).Single(v => v.UserAccountId == folded.Id); + Assert.Equal(merge.Id, closed.VoidedByMergeId); + var recast = Assert.Single(await world.AllVotesAsync(movedKept)); + Assert.Equal((survivor.Id, VoteChoice.Against), (recast.UserAccountId, recast.Choice)); + Assert.Equal(folded.Id, Assert.Single(await world.AllVotesAsync(movedBack)).UserAccountId); + + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + Assert.Equal(2m, (await world.MembershipOfAsync(folded)).EffectiveVotingWeight); + Assert.Equal(1m, (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight); + await using var read = world.Db(); + Assert.Equal(folded.Id, (await read.PatronSupportEvents.SingleAsync()).UserAccountId); + var journal = await world.JournalAsync(merge); + Assert.Contains(journal, e => e.Action == AccountMergeEntryAction.KeptAfterUndo && e.Kind == AccountMergeEntryKind.Vote); + Assert.Contains(journal, e => e.Action == AccountMergeEntryAction.Restored && e.Kind == AccountMergeEntryKind.Membership); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Equal(AccountMergeState.Undone, (await world.Ledger(read).UndoLocalAsync(merge.Id, default)).State); // a second undo is a no-op + } + + [Fact] + public async Task Finalize_moves_Committed_merges_past_their_grace_to_Final_and_leaves_the_rest() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var due = await world.AccountAsync("due"); + var young = await world.AccountAsync("young"); + var dueMerge = await world.CommitAsync(await world.MergeAsync(survivor, due)); + world.Clock.Now = T0.AddDays(5); + var youngMerge = await world.CommitAsync(await world.MergeAsync(survivor, young)); + + world.Clock.Now = T0.AddDays(7); + await using var db = world.Db(); + Assert.Equal(1, await world.Ledger(db).FinalizeDueAsync(world.Clock.Now, default)); + + await using var read = world.Db(); + var final = await read.AccountMerges.SingleAsync(x => x.Id == dueMerge.Id); + Assert.Equal((AccountMergeState.Final, T0.AddDays(7)), (final.State, final.FinalizedUtc)); + Assert.Equal(AccountMergeState.Committed, (await read.AccountMerges.SingleAsync(x => x.Id == youngMerge.Id)).State); + } + + // ---- verdict cut-merge-ledger.s1: the rules each fix decides ---- + + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.HeimdallMoved)] + [InlineData(AccountMergeState.UndoRequested)] + [InlineData(AccountMergeState.HeimdallReversed)] + public async Task Plan_blocks_while_either_account_is_in_a_merge_in_flight(AccountMergeState state) + { + foreach (var survivorSide in new[] { true, false }) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var other = await world.AccountAsync("other"); + await (survivorSide ? world.MergeAsync(survivor, other, state) : world.MergeAsync(other, folded, state)); + + await using var read = world.Db(); + Assert.Contains("merge_in_flight", (await world.Ledger(read).PlanAsync(survivor.Id, folded.Id, default)).Blocks); + } + } + + [Theory] + [InlineData(AccountMergeState.Committed)] + [InlineData(AccountMergeState.Final)] + public async Task A_landed_merges_survivor_cannot_itself_fold_so_an_alias_is_never_a_chain(AccountMergeState state) + { + var world = new World(); + var a = await world.AccountAsync("acct-a"); + var b = await world.AccountAsync("acct-b"); + var c = await world.AccountAsync("acct-c"); + await world.MergeAsync(b, a, state); + + await using var read = world.Db(); + Assert.Contains("folded_is_survivor", (await world.Ledger(read).PlanAsync(c.Id, b.Id, default)).Blocks); + Assert.Empty((await world.Ledger(read).PlanAsync(b.Id, c.Id, default)).Blocks); // the other direction is an ordinary merge + Assert.Equal(b.Id, ((MergeStanding.Folded)(await world.Ledger(read).StandingAsync(a.Id, default))!).SurvivorId); + } + + private static async Task DropMembershipAsync(World world, UserAccount account) + { + await using var db = world.Db(); + db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == account.Id)); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task A_suspension_is_not_laundered_into_a_survivor_without_a_membership_and_undo_removes_the_row_it_made() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + await DropMembershipAsync(world, survivor); + var folded = await world.AccountAsync("folded", MembershipStatus.Suspended); + var merge = await world.MergeAsync(survivor, folded); + + await using (var db = world.Db()) + { + var plan = await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default); + Assert.Contains(plan.Entries, e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.Created }); + Assert.Empty(db.Memberships.Where(x => x.UserAccountId == survivor.Id)); // a plan writes nothing + } + + await world.CommitAsync(merge); + + var carried = await world.MembershipOfAsync(survivor); + Assert.Equal(MembershipStatus.Suspended, carried.Status); + Assert.Equal(AccountMergeLedger.SuspensionReason(merge.Id), carried.SuspensionReason); + Assert.False((await world.ActorAsync(survivor.Id)).IsActiveMember); + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + await using var read = world.Db(); + Assert.Empty(read.Memberships.Where(x => x.UserAccountId == survivor.Id)); + Assert.Contains(await world.JournalAsync(merge), e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.Restored }); + } + + [Fact] + public async Task Undo_keeps_the_membership_a_merge_made_for_a_suspension_once_an_admin_has_taken_it_over() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + await DropMembershipAsync(world, survivor); + var folded = await world.AccountAsync("folded", MembershipStatus.Suspended); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + await using (var db = world.Db()) + { + (await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)).SuspensionReason = "steward decision"; + await db.SaveChangesAsync(); + } + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + Assert.Equal(MembershipStatus.Suspended, (await world.MembershipOfAsync(survivor)).Status); + Assert.Contains(await world.JournalAsync(merge), e => e is { Kind: AccountMergeEntryKind.Standing, Action: AccountMergeEntryAction.KeptAfterUndo }); + } + + [Fact] + public async Task A_moved_vote_carries_the_survivors_weight_not_the_folded_accounts_and_undo_restores_it() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 5m); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var moved = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal((survivor.Id, 1m), (moved.UserAccountId, moved.Weight)); + Assert.Equal(1m, (await world.StateOfAsync(motion, moderator)).VotesFor); + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + var back = Assert.Single(await world.AllVotesAsync(motion)); + Assert.Equal((folded.Id, 5m), (back.UserAccountId, back.Weight)); + } + + [Fact] + public async Task A_moved_vote_takes_the_survivors_weight_after_the_patron_tiers_were_derived_again() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For, 1m); + await using (var db = world.Db()) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = folded.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 100m, + ProviderEventId = "e1", SupportedAtUtc = T0, RecordedAtUtc = T0 + }); + await db.SaveChangesAsync(); + } + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var weight = (await world.MembershipOfAsync(survivor)).EffectiveVotingWeight; + Assert.Equal(2m, weight); + Assert.Equal(weight, Assert.Single(await world.AllVotesAsync(motion)).Weight); + } + + private static async Task<(Guid Both, Guid OnlyFolded)> SeedAppraisalsAsync(World world, UserAccount survivor, UserAccount folded, VoterClass foldedClass) + { + await using var db = world.Db(); + var both = new ConceptSubject { Collection = "c", Key = "both", Title = "Both", RegisteredAtUtc = T0 }; + var only = new ConceptSubject { Collection = "c", Key = "only-folded", Title = "Only folded", RegisteredAtUtc = T0 }; + db.ConceptSubjects.AddRange(both, only); + ConceptAppraisal Row(ConceptSubject subject, UserAccount who, ConceptStance stance, VoterClass voterClass) => new() + { + SubjectId = subject.Id, UserAccountId = who.Id, VoterKey = "account:" + who.Id.ToString("N"), Stance = stance, VoterClass = voterClass, CastAtUtc = T0 + }; + db.ConceptAppraisals.AddRange(Row(both, survivor, ConceptStance.Up, VoterClass.Member), Row(both, folded, ConceptStance.Down, foldedClass), Row(only, folded, ConceptStance.Down, foldedClass)); + await db.SaveChangesAsync(); + return (both.Id, only.Id); + } + + [Fact] + public async Task A_lone_folded_appraisal_is_voided_when_the_survivor_is_not_active() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor", MembershipStatus.PendingApproval); + var folded = await world.AccountAsync("folded"); + var (_, onlyFolded) = await SeedAppraisalsAsync(world, survivor, folded, VoterClass.Member); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using var read = world.Db(); + var row = await read.ConceptAppraisals.IgnoreQueryFilters().SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal((merge.Id, folded.Id), (row.VoidedByMergeId, row.UserAccountId)); + Assert.Equal(0, (await world.Concepts(read).GetTalliesAsync("c", default))["only-folded"].Down); + } + + [Fact] + public async Task A_moved_appraisal_takes_the_member_class_and_undo_gives_back_the_class_and_key_unless_the_survivor_recast_it() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var (_, onlyFolded) = await SeedAppraisalsAsync(world, survivor, folded, VoterClass.Account); + var survivorKey = "account:" + survivor.Id.ToString("N"); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using (var read = world.Db()) + { + var moved = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal((survivor.Id, survivorKey, VoterClass.Member), (moved.UserAccountId, moved.VoterKey, moved.VoterClass)); + } + + await world.SetStateAsync(merge, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(merge.Id, default); + } + + await using (var read = world.Db()) + { + var back = await read.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded); + Assert.Equal((folded.Id, "account:" + folded.Id.ToString("N"), VoterClass.Account), (back.UserAccountId, back.VoterKey, back.VoterClass)); + } + + // Again, but the survivor re-casts the moved appraisal before the undo: it is theirs now and stays. + var folded2 = await world.AccountAsync("folded2"); + await using (var db = world.Db()) + { + db.ConceptAppraisals.Add(new ConceptAppraisal + { + SubjectId = onlyFolded, UserAccountId = folded2.Id, VoterKey = "account:" + folded2.Id.ToString("N"), Stance = ConceptStance.Down, + VoterClass = VoterClass.Account, CastAtUtc = T0 + }); + await db.SaveChangesAsync(); + } + + var second = await world.CommitAsync(await world.MergeAsync(survivor, folded2)); + world.Clock.Now = T0.AddDays(1); + await using (var db = world.Db()) + { + var voter = new VoterIdentity(survivorKey, VoterClass.Member, survivor.Id); + Assert.Equal(CastResult.Changed, await world.Concepts(db).CastAsync("c", "only-folded", voter, ConceptStance.Up, null, default)); + } + + await world.SetStateAsync(second, AccountMergeState.HeimdallReversed); + await using (var db = world.Db()) + { + await world.Ledger(db).UndoLocalAsync(second.Id, default); + } + + await using var after = world.Db(); + var kept = await after.ConceptAppraisals.SingleAsync(x => x.SubjectId == onlyFolded && x.UserAccountId == survivor.Id); + Assert.Equal((survivorKey, ConceptStance.Up), (kept.VoterKey, kept.Stance)); + Assert.Contains(await world.JournalAsync(second), e => e is { Kind: AccountMergeEntryKind.ConceptAppraisal, Action: AccountMergeEntryAction.KeptAfterUndo }); + } + + [Fact] + public async Task Closed_motions_keep_their_votes_with_their_owners_and_journal_nothing_for_them() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var closed = await world.MotionAsync(status: MotionStatus.Passed); + await world.VoteAsync(closed, survivor, VoteChoice.For, 1m); + await world.VoteAsync(closed, folded, VoteChoice.For, 2m); + var lone = await world.MotionAsync(status: MotionStatus.Failed); + await world.VoteAsync(lone, folded, VoteChoice.Against, 2m); + + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + Assert.Equal(new[] { folded.Id, survivor.Id }.Order(), (await world.AllVotesAsync(closed)).Select(x => x.UserAccountId).Order()); + var loneVote = Assert.Single(await world.AllVotesAsync(lone)); + Assert.Equal((folded.Id, 2m), (loneVote.UserAccountId, loneVote.Weight)); // not re-pointed, not re-weighed + Assert.DoesNotContain(await world.JournalAsync(merge), e => e.Kind == AccountMergeEntryKind.Vote); + } + + [Theory] + [InlineData(AccountMergeState.Committed)] + [InlineData(AccountMergeState.Final)] + public async Task Commit_on_a_landed_merge_returns_the_receipt_and_writes_nothing(AccountMergeState state) + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.MergeAsync(survivor, folded, state); + + var receipt = await world.CommitAsync(merge); + + Assert.Equal((merge.Id, state), (receipt.Id, receipt.State)); + Assert.Empty(await world.JournalAsync(merge)); + Assert.Equal(MembershipStatus.Active, (await world.MembershipOfAsync(folded)).Status); + } + + [Fact] + public async Task Undo_is_refused_for_a_merge_that_was_ever_finalized() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + await using (var db = world.Db()) + { + var row = await db.AccountMerges.SingleAsync(x => x.Id == merge.Id); + row.State = AccountMergeState.HeimdallReversed; + row.FinalizedUtc = T0.AddDays(8); + await db.SaveChangesAsync(); + } + + await using var undo = world.Db(); + await Assert.ThrowsAsync(() => world.Ledger(undo).UndoLocalAsync(merge.Id, default)); + Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status); + } + + [Fact] + public async Task A_vote_and_an_appraisal_from_an_actor_resolved_before_the_account_froze_write_nothing() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + var staleActor = await world.ActorAsync(folded.Id); // a request resolved this before the merge was confirmed + await using (var db = world.Db()) + { + db.ConceptSubjects.Add(new ConceptSubject { Collection = "c", Key = "k", Title = "K", RegisteredAtUtc = T0 }); + await db.SaveChangesAsync(); + } + + await world.MergeAsync(survivor, folded, AccountMergeState.Confirmed); + + await using (var db = world.Db()) + { + await world.Motions(db).CastVoteAsync(staleActor, motion.Id, VoteChoice.For, null, default); + Assert.Equal(CastResult.NoVoter, await world.Concepts(db).CastAsync("c", "k", VoterIdentity.For(staleActor, null), ConceptStance.Up, null, default)); + } + + Assert.Empty(await world.AllVotesAsync(motion)); + await using var read = world.Db(); + Assert.Empty(read.ConceptAppraisals.IgnoreQueryFilters()); + + // The survivor, not frozen, still writes. + await using var control = world.Db(); + await world.Motions(control).CastVoteAsync(await world.ActorAsync(survivor.Id), motion.Id, VoteChoice.For, null, default); + Assert.Single(await world.AllVotesAsync(motion)); + } + + [Fact] + public async Task A_merged_membership_is_never_rewritten_by_the_bootstrap_promotion_or_by_suspension() + { + var world = new World(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var admin = await world.AccountAsync("admin", roles: MemberRole.PlatformAdmin); + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + await using (var db = world.Db()) + { + var account = await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == folded.Id); + Assert.False(ApplicationBootstrapper.TryPromoteBootstrapAdmin(account, T0, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance)); + Assert.Equal((MembershipStatus.Merged, false), (account.Membership!.Status, account.Membership.IsPlatformAdmin)); + } + + await using (var db = world.Db()) + { + var suspension = new MembershipSuspensionService(db, new AuditTrailService(db, world.Clock)); + var actor = await world.ActorAsync(admin.Id); + Assert.Equal(SuspensionOutcome.NotApplicable, await suspension.SuspendAsync(actor, folded.Id, "reason", default)); + Assert.Equal(SuspensionOutcome.NotApplicable, await suspension.UnsuspendAsync(actor, folded.Id, "reason", default)); + } + + Assert.Equal(MembershipStatus.Merged, (await world.MembershipOfAsync(folded)).Status); + } + + [Fact] + public async Task The_digest_names_the_survivor_so_two_memberless_survivors_with_identical_records_digest_differently() + { + // A memberless survivor leaves no entry that mentions it: the plan for (A, folded) and for (C, folded) is the same list of + // entries, so only the pair the digest is made for can tell them apart (heimdall-body:finding:cut-merge-saga.s2.digest-pair-binding-unpinned). + var world = new World(); + var folded = await world.AccountAsync("folded"); + var a = await MemberlessAsync(world, "survivor-a"); + var c = await MemberlessAsync(world, "survivor-c"); + + await using var db = world.Db(); + var forA = await world.Ledger(db).PlanAsync(a.Id, folded.Id, default); + var forC = await world.Ledger(db).PlanAsync(c.Id, folded.Id, default); + + Assert.Empty(forA.Blocks); + Assert.Empty(forC.Blocks); + Assert.Equal(forA.Entries.Select(x => (x.Kind, x.Action, x.PriorText)), forC.Entries.Select(x => (x.Kind, x.Action, x.PriorText))); + Assert.NotEqual(forA.Digest(a.Id, folded.Id), forC.Digest(c.Id, folded.Id)); + Assert.NotEqual(forA.Digest(a.Id, folded.Id), forA.Digest(c.Id, folded.Id)); // a digest made for one pair does not confirm the other + Assert.Equal(forA.Digest(a.Id, folded.Id), (await world.Ledger(db).PlanAsync(a.Id, folded.Id, default)).Digest(a.Id, folded.Id)); + } + + private static async Task MemberlessAsync(World world, string name) + { + await using var db = world.Db(); + var account = new UserAccount + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = name, HeimdallAccountId = $"hd-{name}-{Guid.NewGuid():N}", + CreatedAtUtc = T0, LastSeenAtUtc = T0 + }; + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return account; + } +} diff --git a/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs new file mode 100644 index 0000000..e353e0f --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergePostgresTests.cs @@ -0,0 +1,981 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Patronage; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Tests; + +/// +/// The merge tests that need a real server run only when BIFROST_TEST_PG holds a Postgres connection string (no Database=; a role that +/// may create databases). Every other merge test runs on the in-memory provider, which enforces no unique index, transaction, +/// concurrency token or trigger, so the rules that rest on those are proven here, on a database each test creates, builds through the +/// real migrations and the class drops. See AGENTS.md (Live Postgres tests). +/// +public static class PostgresGate +{ + public const string Variable = "BIFROST_TEST_PG"; + + public static string? Skip => + string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(Variable)) + ? $"live Postgres tests: set {Variable} to a connection string without Database= (see AGENTS.md, Live Postgres tests)" + : null; +} + +public sealed class PostgresFactAttribute : FactAttribute +{ + public PostgresFactAttribute() => Skip = PostgresGate.Skip; +} + +public sealed class PostgresTheoryAttribute : TheoryAttribute +{ + public PostgresTheoryAttribute() => Skip = PostgresGate.Skip; +} + +public sealed class MergePostgres : IAsyncLifetime +{ + private readonly List _databases = []; + + private static string? Admin => Environment.GetEnvironmentVariable(PostgresGate.Variable); + + public Task InitializeAsync() => Task.CompletedTask; + + /// A fresh database, built by the real migrations, and a world over it. + public async Task NewWorldAsync() + { + var name = $"bifrost_merge_{Guid.NewGuid():N}"; + await using (var admin = new NpgsqlConnection(Admin)) + { + await admin.OpenAsync(); + await using var create = new NpgsqlCommand($"CREATE DATABASE \"{name}\"", admin); + await create.ExecuteNonQueryAsync(); + } + + _databases.Add(name); + var world = AccountMergeLedgerTests.World.OnPostgres(new NpgsqlConnectionStringBuilder(Admin) { Database = name }.ConnectionString); + await using var db = world.Db(); + await db.Database.MigrateAsync(); + return world; + } + + public async Task DisposeAsync() + { + if (_databases.Count == 0) + { + return; + } + + NpgsqlConnection.ClearAllPools(); + await using var admin = new NpgsqlConnection(Admin); + await admin.OpenAsync(); + foreach (var name in _databases) + { + await using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)", admin); + await drop.ExecuteNonQueryAsync(); + } + } +} + +public sealed class AccountMergePostgresTests(MergePostgres postgres) : IClassFixture +{ + private static PostgresException Pg(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + if (current is PostgresException pg) + { + return pg; + } + } + + throw new Xunit.Sdk.XunitException($"no PostgresException under {error.GetType().Name}"); + } + + /// Runs an action once, just before the first save of a context: the rival writer that lands inside a read-then-save window. + private sealed class BeforeFirstSave(Func rival) : SaveChangesInterceptor + { + private int _fired; + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Interlocked.Exchange(ref _fired, 1) == 0) + { + await rival(); + } + + return result; + } + } + + /// Holds each of two contexts at its save until both have arrived, so both have read before either writes. + private sealed class Rendezvous : SaveChangesInterceptor + { + private readonly Barrier _barrier = new(2); + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken); + return result; + } + } + + private static IEnumerable Chain(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + yield return current; + } + } + + private static string ConnectionOf(AccountMergeLedgerTests.World world) + { + using var db = world.Db(); + return db.Database.GetConnectionString()!; + } + + private static async Task ExecuteAsync(AccountMergeLedgerTests.World world, string sql) + { + await using var db = world.Db(); + await db.Database.ExecuteSqlRawAsync(sql); + } + + // ---- commit atomicity ---- + + [PostgresFact] + public async Task A_commit_that_fails_midway_leaves_nothing_applied_and_the_retry_commits_once() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + var merge = await world.MergeAsync(survivor, folded); + await ExecuteAsync(world, """ + CREATE FUNCTION fail_merged() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW."Status" = 'Merged' THEN RAISE EXCEPTION 'injected fault'; END IF; RETURN NEW; END $$; + CREATE TRIGGER fail_merged BEFORE UPDATE ON "Memberships" FOR EACH ROW EXECUTE FUNCTION fail_merged(); + """); + + var fault = await Assert.ThrowsAsync(() => world.CommitAsync(merge)); + Assert.Contains("injected fault", Pg(fault).MessageText); + + await using (var check = world.Db()) + { + Assert.Equal(AccountMergeState.HeimdallMoved, (await check.AccountMerges.SingleAsync()).State); + Assert.Empty(check.AccountMergeEntries); + Assert.All(await check.Votes.IgnoreQueryFilters().ToListAsync(), v => Assert.Null(v.VoidedByMergeId)); + Assert.Equal(MembershipStatus.Active, (await check.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status); + } + + await ExecuteAsync(world, "DROP TRIGGER fail_merged ON \"Memberships\""); + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); + var journal = await world.JournalAsync(merge); + Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(merge)).State); // a second commit is a no-op + Assert.Equal(journal.Count, (await world.JournalAsync(merge)).Count); + } + + [PostgresFact] + public async Task The_journal_refuses_a_second_entry_with_the_same_merge_and_seq() + { + var world = await postgres.NewWorldAsync(); + var merge = await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded")); + await using var db = world.Db(); + AccountMergeEntry Entry() => new() { MergeId = merge.Id, Seq = 1, Kind = AccountMergeEntryKind.Vote, Action = AccountMergeEntryAction.Voided, RecordId = Guid.NewGuid(), AtUtc = AccountMergeLedgerTests.T0 }; + db.AccountMergeEntries.Add(Entry()); + await db.SaveChangesAsync(); + db.AccountMergeEntries.Add(Entry()); + + var error = await Assert.ThrowsAsync(() => db.SaveChangesAsync()); + + Assert.Equal(("23505", "IX_AccountMergeEntries_MergeId_Seq"), (Pg(error).SqlState, Pg(error).ConstraintName)); + } + + [PostgresFact] + public async Task Two_commits_of_the_same_merge_racing_apply_it_exactly_once() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.For); + await world.VoteAsync(motion, folded, VoteChoice.Against); + var merge = await world.MergeAsync(survivor, folded); + var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world)); + + async Task TryCommit() + { + try + { + await committers.CommitAsync(merge); + return null; + } + catch (Exception error) + { + return error; + } + } + + var results = await Task.WhenAll(Task.Run(TryCommit), Task.Run(TryCommit)); + + // The second commit waits on the folded account's exclusive lock, then finds the merge landed and returns its receipt. + Assert.All(results, e => Assert.Null(e)); + var journal = await world.JournalAsync(merge); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Equal(1, journal.Count(x => x.Kind == AccountMergeEntryKind.Vote)); + Assert.Single((await world.AllVotesAsync(motion)).Where(v => v.VoidedByMergeId == merge.Id)); + } + + [PostgresFact] + public async Task A_voided_vote_leaves_every_tally_on_a_real_database() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var moderator = await world.AccountAsync("mod", moderator: true); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, survivor, VoteChoice.Against, 1m); + await world.VoteAsync(motion, folded, VoteChoice.For, 3m); + + await world.CommitAsync(await world.MergeAsync(survivor, folded)); + + var item = await world.StateOfAsync(motion, moderator); + Assert.Equal((0m, 1m), (item.VotesFor, item.VotesAgainst)); + Assert.Equal(MotionStatus.Failed, await world.CloseAsync(motion, moderator)); + } + + // ---- the sweeper against the saga ---- + + [PostgresFact] + public async Task The_sweep_does_not_overwrite_an_UndoRequested_written_after_it_read_the_merge() + { + var plain = await postgres.NewWorldAsync(); + var merge = await plain.CommitAsync(await plain.MergeAsync(await plain.AccountAsync("survivor"), await plain.AccountAsync("folded"))); + var sweeper = AccountMergeLedgerTests.World.OnPostgres( + ConnectionOf(plain), new BeforeFirstSave(() => plain.SetStateAsync(merge, AccountMergeState.UndoRequested))); + + await using (var db = sweeper.Db()) + { + Assert.Equal(0, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default)); + } + + await using var read = plain.Db(); + var row = await read.AccountMerges.SingleAsync(); + Assert.Equal(AccountMergeState.UndoRequested, row.State); + Assert.Null(row.FinalizedUtc); + } + + [PostgresFact] + public async Task An_UndoRequested_written_from_a_stale_Committed_read_cannot_overwrite_Final() + { + var world = await postgres.NewWorldAsync(); + var merge = await world.CommitAsync(await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"))); + await using var saga = world.Db(); + var stale = await saga.AccountMerges.SingleAsync(x => x.Id == merge.Id); // the saga read Committed + + await using (var sweep = world.Db()) + { + Assert.Equal(1, await world.Ledger(sweep).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default)); + } + + stale.State = AccountMergeState.UndoRequested; + await Assert.ThrowsAsync(() => saga.SaveChangesAsync()); + await using var read = world.Db(); + Assert.Equal(AccountMergeState.Final, (await read.AccountMerges.SingleAsync()).State); + } + + [PostgresFact] + public async Task A_sweep_that_loses_one_merge_to_the_saga_still_finalizes_the_others() + { + var plain = await postgres.NewWorldAsync(); + var survivor = await plain.AccountAsync("survivor"); + await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-a"))); + await plain.CommitAsync(await plain.MergeAsync(survivor, await plain.AccountAsync("folded-b"))); + var sweeper = AccountMergeLedgerTests.World.OnPostgres( + ConnectionOf(plain), new BeforeFirstSave(async () => + { + // Whichever merge the sweep saves first, the saga takes one of the two before it does. + await using var rival = plain.Db(); + var taken = await rival.AccountMerges.Where(x => x.State == AccountMergeState.Committed).OrderBy(x => x.Id).FirstAsync(); + taken.State = AccountMergeState.UndoRequested; + await rival.SaveChangesAsync(); + })); + + await using (var db = sweeper.Db()) + { + Assert.Equal(1, await sweeper.Ledger(db).FinalizeDueAsync(AccountMergeLedgerTests.T0.AddDays(8), default)); + } + + await using var read = plain.Db(); + Assert.Equal([AccountMergeState.Final, AccountMergeState.UndoRequested], (await read.AccountMerges.ToListAsync()).Select(x => x.State).Order()); + } + + // ---- overlapping merges, refused by the database ---- + + [PostgresFact] + public async Task A_second_live_merge_folding_the_same_account_is_refused_and_a_finished_one_frees_it() + { + var world = await postgres.NewWorldAsync(); + var s1 = await world.AccountAsync("acct-s1"); + var s2 = await world.AccountAsync("acct-s2"); + var folded = await world.AccountAsync("folded"); + + var second = await world.MergeAsync(s2, folded, AccountMergeState.Proposed); // proposals may coexist + var first = await world.MergeAsync(s1, folded, AccountMergeState.Confirmed); + + var refused = await Assert.ThrowsAsync(() => world.SetStateAsync(second, AccountMergeState.Confirmed)); + Assert.Equal(("23505", "UX_AccountMerges_FoldedUserAccountId_Live"), (Pg(refused).SqlState, Pg(refused).ConstraintName)); + var inserted = await Assert.ThrowsAsync(() => world.MergeAsync(s2, folded, AccountMergeState.Final)); + Assert.Equal("23505", Pg(inserted).SqlState); + + await world.SetStateAsync(first, AccountMergeState.Undone); + await world.SetStateAsync(second, AccountMergeState.Confirmed); // the slot is free again + } + + [PostgresTheory] + [InlineData(AccountMergeState.Confirmed, AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Final, AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed, AccountMergeState.HeimdallMoved)] + public async Task A_chain_is_refused_in_either_arrival_order_and_the_same_survivor_twice_is_fine(AccountMergeState earlier, AccountMergeState later) + { + var world = await postgres.NewWorldAsync(); + foreach (var survivorFirst in new[] { true, false }) + { + var tag = survivorFirst ? "a" : "b"; + var s = await world.AccountAsync("acct-s" + tag); + var f = await world.AccountAsync("acct-f" + tag); + var g = await world.AccountAsync("acct-g" + tag); + var other = await world.AccountAsync("other" + tag); + + await world.MergeAsync(s, other, AccountMergeState.Confirmed); // one survivor may carry two folded accounts + await world.MergeAsync(s, f, earlier); + // f survives a live merge and folds into g, or s folds into g while it survives one: both are chains. + var chain = await Assert.ThrowsAsync(() => survivorFirst ? world.MergeAsync(f, g, later) : world.MergeAsync(g, s, later)); + Assert.Equal("23P01", Pg(chain).SqlState); + } + } + + [PostgresFact] + public async Task Two_transactions_racing_to_chain_the_same_account_cannot_both_commit() + { + var world = await postgres.NewWorldAsync(); + var s = await world.AccountAsync("acct-s"); + var f = await world.AccountAsync("acct-f"); + var g = await world.AccountAsync("acct-g"); + await using var first = world.Db(); + await using var firstTransaction = await first.Database.BeginTransactionAsync(); + first.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = s.Id, FoldedUserAccountId = f.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 }); + await first.SaveChangesAsync(); + + await using var second = world.Db(); + await using var secondTransaction = await second.Database.BeginTransactionAsync(); + second.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = f.Id, FoldedUserAccountId = g.Id, State = AccountMergeState.Confirmed, CreatedUtc = AccountMergeLedgerTests.T0 }); + var racing = second.SaveChangesAsync(); + + await Task.Delay(1500); + Assert.False(racing.IsCompleted); // it waits on the first, which holds the lock on f + await firstTransaction.CommitAsync(); + + var refused = await Assert.ThrowsAsync(() => racing.WaitAsync(TimeSpan.FromSeconds(30))); + Assert.Equal("23P01", Pg(refused).SqlState); + } + + // ---- rollback ---- + + [PostgresFact] + public async Task Reverting_the_migration_is_refused_while_any_merge_exists_even_one_that_voided_nothing_and_allowed_after() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await world.CommitAsync(await world.MergeAsync(survivor, folded)); // moves and voids nothing: only the Merged membership remains + + await using (var db = world.Db()) + { + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); + Assert.Contains("roll-forward only", Pg(error).MessageText); + // The acks migration reverts first (it holds only acks); the saga migration is the one that refuses. + Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last()); + } + + await ExecuteAsync(world, "DELETE FROM \"AccountMergeEntries\"; DELETE FROM \"AccountMerges\""); + await using var down = world.Db(); + await down.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); + Assert.Equal("20261113120000_AddPasskeyCredentials", (await down.Database.GetAppliedMigrationsAsync()).Last()); + await down.GetService().MigrateAsync(); // and up again, trigger and index and all + Assert.Equal("20261114140000_AddAccountMergeNoticeAcks", (await down.Database.GetAppliedMigrationsAsync()).Last()); + } + + // ---- restage and intake journal ---- + + [PostgresFact] + public async Task Two_merges_into_one_survivor_restaging_at_once_leave_one_current_patron_snapshot_and_the_loser_commits_on_retry() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var first = await world.AccountAsync("first"); + var second = await world.AccountAsync("second"); + await using (var db = world.Db()) + { + foreach (var who in new[] { first, second }) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = who.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 1000m, + SupportedAtUtc = AccountMergeLedgerTests.T0, RecordedAtUtc = AccountMergeLedgerTests.T0 + }); + } + + await db.SaveChangesAsync(); + } + + var one = await world.MergeAsync(survivor, first); + var two = await world.MergeAsync(survivor, second); + var committers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new Rendezvous()); + + async Task TryCommit(AccountMerge merge) + { + try + { + await committers.CommitAsync(merge); + return null; + } + catch (Exception error) + { + return error; + } + } + + var results = await Task.WhenAll(Task.Run(() => TryCommit(one)), Task.Run(() => TryCommit(two))); + + Assert.Equal(1, results.Count(x => x is null)); + Assert.All(results.OfType(), e => Assert.Contains(Chain(e), x => x is PostgresException { SqlState: "23505", ConstraintName: "UX_TierSnapshots_MembershipId_CurrentPatron" })); + var loser = results[0] is null ? two : one; + Assert.Equal(AccountMergeState.Committed, (await world.CommitAsync(loser)).State); + await using var read = world.Db(); + var membership = await read.Memberships.Include(x => x.TierSnapshots).SingleAsync(x => x.UserAccountId == survivor.Id); + Assert.Single(membership.TierSnapshots, x => x.Kind == TierSnapshotKind.Patron && x.IsCurrent); + Assert.All(await read.PatronSupportEvents.ToListAsync(), e => Assert.Equal(survivor.Id, e.UserAccountId)); + } + + [PostgresFact] + public async Task A_patron_refresh_that_loses_the_restage_race_reads_the_winners_snapshot_and_derives_again() + { + var world = await postgres.NewWorldAsync(); + var patron = await world.AccountAsync("patron"); + await using (var db = world.Db()) + { + db.PatronSupportEvents.Add(new PatronSupportEvent + { + UserAccountId = patron.Id, Kind = PatronSupportEventKind.RecurringSupportSnapshot, IsCurrentRecurringSupport = true, Amount = 1000m, + SupportedAtUtc = AccountMergeLedgerTests.T0, RecordedAtUtc = AccountMergeLedgerTests.T0 + }); + await db.SaveChangesAsync(); + } + + var clock = world.Clock; + var rival = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world)); + var loser = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + await using var rivalDb = rival.Db(); + await new PatronageService(rivalDb, new AuditTrailService(rivalDb, clock), clock).RefreshPatronTierSnapshotAsync(null, patron.Id, default); + })); + + await using (var db = loser.Db()) + { + var summary = await new PatronageService(db, new AuditTrailService(db, clock), clock).RefreshPatronTierSnapshotAsync(null, patron.Id, default); + Assert.True(summary.VotingWeight > 0m); + } + + await using var read = world.Db(); + var snapshots = await read.TierSnapshots.Where(x => x.Kind == TierSnapshotKind.Patron).ToListAsync(); + Assert.Single(snapshots, x => x.IsCurrent); + Assert.Equal(2, snapshots.Count); // the winner's, demoted by the retry, and the retry's own + } + + [PostgresFact] + public async Task The_migration_keeps_only_the_newest_of_duplicate_current_patron_snapshots_before_it_builds_the_index() + { + var world = await postgres.NewWorldAsync(); + var patron = await world.AccountAsync("patron"); + await using var db = world.Db(); + await db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials"); // the index does not exist yet + var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == patron.Id); + TierSnapshot Snapshot(string label, int minutes) => new() + { + MembershipId = membership.Id, Kind = TierSnapshotKind.Patron, Label = label, Weight = 1m, IsCurrent = true, + EffectiveFromUtc = AccountMergeLedgerTests.T0, CapturedAtUtc = AccountMergeLedgerTests.T0.AddMinutes(minutes) + }; + db.TierSnapshots.AddRange(Snapshot("older", 0), Snapshot("newer", 5)); + await db.SaveChangesAsync(); + + await db.GetService().MigrateAsync(); + + await using var read = world.Db(); + Assert.Equal("newer", (await read.TierSnapshots.SingleAsync(x => x.IsCurrent)).Label); + Assert.Equal(2, await read.TierSnapshots.CountAsync()); + } + + [PostgresFact] + public async Task A_journalled_patron_intake_takes_the_next_seq_after_the_commits_entries_and_is_idempotent() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var merge = await world.CommitAsync(await world.MergeAsync(survivor, folded)); + var committed = (await world.JournalAsync(merge)).Count; + Assert.True(committed > 0); + + var events = new[] { $"WH-{Guid.NewGuid():N}", $"WH-{Guid.NewGuid():N}" }; + foreach (var id in events.Concat(events)) // each twice: the provider retries + { + await using var db = world.Db(); + await new PatronageService(db, new AuditTrailService(db, world.Clock), world.Clock).RecordSupportEventAsync( + null, folded.Id, PatronSupportEventKind.OneTimeDonation, 1m, "USD", "ext", ExternalPatronProvider.PayPal, id, + string.Empty, string.Empty, AccountMergeLedgerTests.T0, false, string.Empty, default); + } + + var journal = await world.JournalAsync(merge); + Assert.Equal(committed + 2, journal.Count); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + } + + [PostgresTheory] + [InlineData(AccountMergeState.Proposed)] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.HeimdallMoved)] + [InlineData(AccountMergeState.Final)] + [InlineData(AccountMergeState.UndoRequested)] + [InlineData(AccountMergeState.HeimdallReversed)] + [InlineData(AccountMergeState.Undone)] + [InlineData(AccountMergeState.Failed)] + public async Task Reverting_the_migration_is_refused_for_a_merge_in_any_state(AccountMergeState state) + { + var world = await postgres.NewWorldAsync(); + await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"), state); + + await using var db = world.Db(); + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261113120000_AddPasskeyCredentials")); + Assert.Contains("roll-forward only", Pg(error).MessageText); + } + + // ---- the write gate ---- + + [PostgresFact] + public async Task A_vote_that_read_no_merge_saves_before_a_merge_can_freeze_the_account_and_the_commit_then_moves_it() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + var actor = await world.ActorAsync(folded.Id); + Task? freeze = null; + var blocked = false; + var voters = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + // The vote has read its standing (none) and is about to save: the freeze lands here and must wait for the save. + freeze = Task.Run(() => world.MergeAsync(survivor, folded, AccountMergeState.Confirmed)); + await Task.Delay(1500); + blocked = !freeze.IsCompleted; + })); + + await using (var db = voters.Db()) + { + await voters.Motions(db).CastVoteAsync(actor, motion.Id, VoteChoice.For, null, default); + } + + var merge = await freeze!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + await world.SetStateAsync(merge, AccountMergeState.HeimdallMoved); + await world.CommitAsync(merge); + + var votes = await world.AllVotesAsync(motion); + Assert.Equal(survivor.Id, Assert.Single(votes).UserAccountId); // the commit saw the vote and moved it: no second voice stays on the folded account + Assert.Single(await world.JournalAsync(merge), x => x.Kind == AccountMergeEntryKind.Vote); + } + + [PostgresFact] + public async Task A_patron_event_that_read_a_Frozen_account_saves_before_the_commit_reduces_and_the_commit_moves_it() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var merge = await world.MergeAsync(survivor, folded); // HeimdallMoved: the folded account is Frozen + Task? commit = null; + var blocked = false; + var recorders = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + commit = Task.Run(() => world.CommitAsync(merge)); // takes the folded account's exclusive lock before it reduces + await Task.Delay(1500); + blocked = !commit.IsCompleted; + })); + + await using (var db = recorders.Db()) + { + await new PatronageService(db, new AuditTrailService(db, recorders.Clock), recorders.Clock).RecordSupportEventAsync( + null, folded.Id, PatronSupportEventKind.OneTimeDonation, 1m, "USD", "ext", ExternalPatronProvider.PayPal, "WH-frozen", + string.Empty, string.Empty, AccountMergeLedgerTests.T0, false, string.Empty, default); + } + + await commit!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + await using var read = world.Db(); + Assert.Equal(survivor.Id, (await read.PatronSupportEvents.SingleAsync()).UserAccountId); // the reduction found the event and moved it + Assert.Single(await world.JournalAsync(merge), x => x.Kind == AccountMergeEntryKind.PatronSupportEvent); + } + + [PostgresFact] + public async Task A_sealed_reveal_that_retries_on_a_conflict_keeps_its_lock_so_no_merge_freezes_the_account_between_attempts() + { + const string salt = "00112233445566778899aabbccddeeff"; + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var other = await world.AccountAsync("other"); + var moderator = await world.AccountAsync("moderator", moderator: true); + SealedBallotService Sealed(AccountMergeLedgerTests.World w, BifrostDbContext db) => new(db, new AuditTrailService(db, w.Clock), w.Clock); + SealedBallotDocument Doc(Guid motionId, string reason) => new(SealedBallotDocument.SchemaId, motionId, 1, "Ana", reason, salt); + + await using var setup = world.Db(); + var motionId = (await Sealed(world, setup).OpenAsync(await world.ActorAsync(moderator.Id), + new OpenSealedMotionCommand("Who", "Blind.", ["Ana", "Bo"], AccountMergeLedgerTests.T0.AddHours(1), AccountMergeLedgerTests.T0.AddHours(2)), default)).Id; + foreach (var (account, reason) in new[] { (folded, "mine"), (other, "theirs") }) + { + await using var db = world.Db(); + await Sealed(world, db).CommitAsync(await world.ActorAsync(account.Id), motionId, SealedBallotDocument.Commit(Doc(motionId, reason), account.Id), default); + } + + Task? freeze = null; + var blocked = false; + var revealers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + // The first attempt has read its standing (none). A freeze starts and must wait; another member's reveal then lands first, + // so this attempt's save conflicts on RevealSeq and the reveal retries while the freeze still waits. + freeze = Task.Run(() => world.MergeAsync(survivor, folded, AccountMergeState.Confirmed)); + await using var rival = world.Db(); + world.Clock.Now = AccountMergeLedgerTests.T0.AddMinutes(90); + await Sealed(world, rival).RevealAsync(await world.ActorAsync(other.Id), motionId, Doc(motionId, "theirs"), default); + await Task.Delay(1500); + blocked = !freeze.IsCompleted; + })); + revealers.Clock.Now = AccountMergeLedgerTests.T0.AddMinutes(90); + var actor = await world.ActorAsync(folded.Id); + + await using (var db = revealers.Db()) + { + Assert.True((await Sealed(revealers, db).RevealAsync(actor, motionId, Doc(motionId, "mine"), default)).Revealed); + } + + await freeze!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + } + + // ---- the saga: races the State token and the live-merge index carry ---- + + /// Holds the first save of each of two contexts until both have arrived: both have read, neither has written. + private sealed class FirstSaveRendezvous : SaveChangesInterceptor + { + private readonly Barrier _barrier = new(2); + private readonly HashSet _seen = []; + + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + bool first; + lock (_seen) + { + first = _seen.Add(eventData.Context!.ContextId.InstanceId); + } + + if (first) + { + await Task.Run(() => _barrier.SignalAndWait(TimeSpan.FromSeconds(30)), cancellationToken); + } + + return result; + } + } + + /// The fake Heimdall's account model behind the real move client, one key ring for every saga built over it. + private sealed class Heimdall + { + private readonly EphemeralDataProtectionProvider _keys = new(); + private readonly HeimdallIdentityMoveClient _mover; + + public Heimdall(AccountMergeLedgerTests.World clockOwner) + { + Fake = new FakeHeimdall { RequiredAppSecret = HeimdallHost.AppSecret, Now = () => clockOwner.Clock.Now }; + var options = new HeimdallOptions { BaseUrl = HeimdallShapes.Issuer, AppSecret = HeimdallHost.AppSecret }; + var client = new HttpClient(Fake); + HeimdallAuthService.ConfigureClient(client, options); + _mover = new HeimdallIdentityMoveClient(client, Options.Create(options), NullLogger.Instance); + } + + public FakeHeimdall Fake { get; } + + public AccountMergeSaga Saga(AccountMergeLedgerTests.World world, BifrostDbContext db) => + new(db, world.Ledger(db), _mover, _keys, new AuditTrailService(db, world.Clock), world.Clock, NullLogger.Instance); + + /// A world account with one login at the fake Heimdall and a weak proof through it. + public MergeProof Proof(UserAccount account, string provider) => + new(Fake.Moves.Issue(account.HeimdallAccountId, Fake.Moves.AddLogin(account.HeimdallAccountId, provider)), account.HeimdallAccountId, provider); + } + + [PostgresFact] + public async Task Two_concurrent_advances_of_one_confirmed_merge_make_one_move_commit_once_and_journal_once() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var motion = await world.MotionAsync(); + await world.VoteAsync(motion, folded, VoteChoice.For); + var heimdall = new Heimdall(world); + var survivorProof = heimdall.Proof(survivor, "twitch"); + var foldedProof = heimdall.Proof(folded, "discord"); + Guid mergeId; + await using (var db = world.Db()) + { + var saga = heimdall.Saga(world, db); + var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id); + mergeId = Assert.IsType(await saga.ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, survivorProof, [foldedProof]), default)).MergeId; + } + + var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous()); + async Task Advance() + { + await using var db = racers.Db(); + return await heimdall.Saga(racers, db).AdvanceAsync(mergeId, default); + } + + var both = await Task.WhenAll(Task.Run(Advance), Task.Run(Advance)); + + Assert.All(both, x => Assert.Equal(AccountMergeState.Committed, x.State)); + Assert.Single(heimdall.Fake.Moves.Stored); + var journal = await world.JournalAsync(new AccountMerge { Id = mergeId }); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.Vote); + Assert.Equal(survivor.Id, Assert.Single(await world.AllVotesAsync(motion)).UserAccountId); + } + + [PostgresFact] + public async Task Two_confirms_folding_the_same_account_leave_one_row_and_the_other_is_blocked_by_the_database() + { + var world = await postgres.NewWorldAsync(); + var first = await world.AccountAsync("first"); + var second = await world.AccountAsync("second"); + var folded = await world.AccountAsync("folded"); + var heimdall = new Heimdall(world); + var proofs = new Dictionary(); + foreach (var survivor in new[] { first, second }) + { + await using var db = world.Db(); + proofs[survivor.Id] = (heimdall.Proof(survivor, "twitch"), (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id)); + } + + var foldedProof = heimdall.Proof(folded, "discord"); + var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous()); + async Task Confirm(UserAccount survivor) + { + await using var db = racers.Db(); + return await heimdall.Saga(racers, db).ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, proofs[survivor.Id].Digest, proofs[survivor.Id].Survivor, [foldedProof]), default); + } + + var results = await Task.WhenAll(Task.Run(() => Confirm(first)), Task.Run(() => Confirm(second))); + + Assert.Single(results.OfType()); + var blocked = Assert.Single(results.OfType()); + Assert.Contains(AccountMergeSaga.MergeInFlight, blocked.Codes); + await using var check = world.Db(); + Assert.Equal(1, await check.AccountMerges.CountAsync()); + } + + [PostgresFact] + public async Task Two_merges_cannot_share_a_Heimdall_move_id_or_a_reverse_move_id() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var one = await world.MergeAsync(survivor, await world.AccountAsync("one"), AccountMergeState.Confirmed); + var two = await world.MergeAsync(survivor, await world.AccountAsync("two"), AccountMergeState.Confirmed); + + foreach (var column in new[] { "HeimdallMoveId", "HeimdallReverseMoveId" }) + { + await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{one.Id}'"); + var error = await Assert.ThrowsAnyAsync(() => + ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"{column}\" = 'shared-0123456789abcdef' WHERE \"Id\" = '{two.Id}'")); + Assert.Equal("23505", Pg(error).SqlState); + } + } + + [PostgresFact] + public async Task Confirm_holds_the_actors_write_lock_until_its_row_is_saved() + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var heimdall = new Heimdall(world); + var proof = heimdall.Proof(survivor, "twitch"); + var foldedProof = heimdall.Proof(folded, "discord"); + string digest; + await using (var db = world.Db()) + { + digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id); + } + + Task? exclusive = null; + var blocked = false; + var confirmers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(async () => + { + // A writer that needs the actor's exclusive lock (a commit freezing it) arrives while the confirm is about to save. + exclusive = Task.Run(async () => + { + await using var connection = new NpgsqlConnection(ConnectionOf(world)); + await connection.OpenAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await using var command = new NpgsqlCommand("SELECT pg_advisory_xact_lock(hashtextextended(@key, 0))", connection, transaction); + command.Parameters.AddWithValue("key", survivor.Id.ToString()); + await command.ExecuteNonQueryAsync(); + }); + await Task.Delay(1500); + blocked = !exclusive.IsCompleted; + })); + + await using (var db = confirmers.Db()) + { + Assert.IsType(await heimdall.Saga(confirmers, db).ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, proof, [foldedProof]), default)); + } + + await exclusive!.WaitAsync(TimeSpan.FromSeconds(30)); + Assert.True(blocked); + } + + /// A Confirmed merge with sealed proofs, stopped on the given outcome, and the operator who will settle it. + private async Task<(AccountMergeLedgerTests.World World, Heimdall Heimdall, Guid Id, string MoveId, Guid Operator)> StuckConfirmedAsync(string outcome) + { + var world = await postgres.NewWorldAsync(); + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + var operatorId = (await world.AccountAsync("operator")).Id; + var heimdall = new Heimdall(world); + Guid id; + await using (var db = world.Db()) + { + var digest = (await world.Ledger(db).PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id); + id = Assert.IsType(await heimdall.Saga(world, db).ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, heimdall.Proof(survivor, "twitch"), [heimdall.Proof(folded, "discord")]), default)).MergeId; + } + + await ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'Confirmed', \"LastOutcome\" = '{outcome}' WHERE \"Id\" = '{id}'"); + await using var read = world.Db(); + var merge = await read.AccountMerges.AsNoTracking().SingleAsync(); + Assert.NotNull(merge.SealedProofs); + return (world, heimdall, id, merge.HeimdallMoveId!, operatorId); + } + + [PostgresFact] + public async Task A_settle_that_loses_to_a_rival_state_write_inside_its_save_window_writes_nothing_and_no_audit_row() + { + var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("move_id_conflict"); + var settler = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new BeforeFirstSave(() => + ExecuteAsync(world, $"UPDATE \"AccountMerges\" SET \"State\" = 'HeimdallMoved' WHERE \"Id\" = '{id}'"))); + await using (var db = settler.Db()) + { + Assert.IsType(await heimdall.Saga(settler, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default)); + } + + await using var read = world.Db(); + var merge = await read.AccountMerges.AsNoTracking().SingleAsync(); + Assert.Equal(AccountMergeState.HeimdallMoved, merge.State); + Assert.NotNull(merge.SealedProofs); + Assert.Equal("move_id_conflict", merge.LastOutcome); + Assert.Equal(0, await read.AuditEvents.CountAsync()); + } + + [PostgresFact] + public async Task A_settle_whose_audit_insert_fails_rolls_the_transition_back() + { + var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("receipt_mismatch"); + await ExecuteAsync(world, """ + CREATE FUNCTION fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'injected audit fault'; END $$; + CREATE TRIGGER fail_audit BEFORE INSERT ON "AuditEvents" FOR EACH ROW EXECUTE FUNCTION fail_audit(); + """); + await using (var db = world.Db()) + { + await Assert.ThrowsAsync(() => heimdall.Saga(world, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default)); + } + + await using var read = world.Db(); + var merge = await read.AccountMerges.AsNoTracking().SingleAsync(); + Assert.Equal(AccountMergeState.Confirmed, merge.State); + Assert.NotNull(merge.SealedProofs); + Assert.Equal("receipt_mismatch", merge.LastOutcome); + Assert.Equal(0, await read.AuditEvents.CountAsync()); + } + + [PostgresFact] + public async Task Two_concurrent_settles_of_one_stuck_merge_settle_once_lose_once_and_audit_once() + { + var (world, heimdall, id, moveId, operatorId) = await StuckConfirmedAsync("proofs_unreadable"); + var racers = AccountMergeLedgerTests.World.OnPostgres(ConnectionOf(world), new FirstSaveRendezvous()); + async Task Settle() + { + await using var db = racers.Db(); + return await heimdall.Saga(racers, db).SettleStuckAsync(id, SettleChoice.FailForward, moveId, operatorId, default); + } + + var both = await Task.WhenAll(Task.Run(Settle), Task.Run(Settle)); + + Assert.Single(both.OfType()); + Assert.Single(both.OfType()); + await using var read = world.Db(); + Assert.Equal(1, await read.AuditEvents.CountAsync()); + Assert.Equal(AccountMergeState.Failed, (await read.AccountMerges.AsNoTracking().SingleAsync()).State); + } + + [PostgresFact] + public async Task The_saga_migration_adds_and_drops_its_columns_and_keys_and_its_Down_is_refused_while_any_merge_exists() + { + var world = await postgres.NewWorldAsync(); + await world.MergeAsync(await world.AccountAsync("survivor"), await world.AccountAsync("folded"), AccountMergeState.Confirmed); + await using (var db = world.Db()) + { + var error = await Assert.ThrowsAnyAsync(() => db.GetService().MigrateAsync("20261114120000_AddAccountMerges")); + Assert.Contains("AddAccountMergeSaga cannot be reverted", Pg(error).MessageText); + // The acks migration reverts first (it holds only acks); the saga migration is the one that refuses. + Assert.Equal("20261114130000_AddAccountMergeSaga", (await db.Database.GetAppliedMigrationsAsync()).Last()); + } + + await ExecuteAsync(world, "DELETE FROM \"AccountMerges\""); + await using var down = world.Db(); + await down.GetService().MigrateAsync("20261114120000_AddAccountMerges"); + Assert.Equal(0, await ScalarAsync(world, "SELECT count(*) FROM information_schema.columns WHERE table_name = 'AccountMerges' AND column_name IN ('HeimdallMoveId','SealedProofs','Attempts')")); + Assert.Equal(0, await ScalarAsync(world, "SELECT count(*) FROM pg_indexes WHERE tablename = 'AccountMerges' AND indexname LIKE 'IX_AccountMerges_Heimdall%'")); + await down.GetService().MigrateAsync(); + Assert.Equal(3, await ScalarAsync(world, "SELECT count(*) FROM information_schema.columns WHERE table_name = 'AccountMerges' AND column_name IN ('HeimdallMoveId','SealedProofs','Attempts')")); + Assert.Equal(2, await ScalarAsync(world, "SELECT count(*) FROM pg_indexes WHERE tablename = 'AccountMerges' AND indexname LIKE 'IX_AccountMerges_Heimdall%'")); + } + + private static async Task ScalarAsync(AccountMergeLedgerTests.World world, string sql) + { + await using var connection = new NpgsqlConnection(ConnectionOf(world)); + await connection.OpenAsync(); + await using var command = new NpgsqlCommand(sql, connection); + return (long)(await command.ExecuteScalarAsync())!; + } +} diff --git a/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs new file mode 100644 index 0000000..0e73216 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeSagaTests.cs @@ -0,0 +1,732 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Bifrost.Web.Tests; + +/// +/// The merge saga from its consumers' side: a request scope of the real host (DI, the move client's HttpClient, Data Protection, the shared +/// TestClock) driving Confirm, Advance and RequestUndo against the fake Heimdall's account model (Heimdall 4fe367a's contract). Every +/// operation takes a fresh scope, as a request or a sweeper tick would, so what survives is only what was saved. +/// +public sealed class AccountMergeSagaTests +{ + private const string Passkey = "bifrost-passkey"; + + internal sealed record Party(Guid Id, string HeimdallId, IReadOnlyDictionary Logins); + + /// A host plus the helpers every test needs: accounts with logins at the fake Heimdall, proofs, a scope per operation. + internal sealed class Rig(bool realSessions = false, bool accountMerge = false, string? chatNoticesToken = null) : IDisposable + { + public HeimdallHost Host { get; } = new(realSessions: realSessions, accountMerge: accountMerge, chatNoticesToken: chatNoticesToken); + + public FakeMoves Moves => Host.Heimdall.Moves; + + public TestClock Clock => Host.Clock; + + public void Dispose() => Host.Dispose(); + + public async Task AccountAsync(string name, params string[] providers) + { + var heimdallId = HeimdallShapes.NewAccountId(); + var logins = providers.ToDictionary(x => x, x => Moves.AddLogin(heimdallId, x)); + using var scope = Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount + { + DisplayName = name, BifrostIdentity = name, NormalizedBifrostIdentity = BifrostIdentityService.NormalizeIdentity(name), + HeimdallAccountId = heimdallId, CreatedAtUtc = Clock.Now, LastSeenAtUtc = Clock.Now, + MemberProfile = new MemberProfile { Nickname = name, Headline = "seed", UpdatedAtUtc = Clock.Now } + }; + account.Membership = new Membership { UserAccountId = account.Id, Status = MembershipStatus.Active }; + db.UserAccounts.Add(account); + await db.SaveChangesAsync(); + return new Party(account.Id, heimdallId, logins); + } + + /// A fresh sign-in through the login of : a weak proof of that login only. + public MergeProof Weak(Party who, string provider) => new(Moves.Issue(who.HeimdallId, who.Logins[provider]), who.HeimdallId, provider); + + /// A fresh sign-in of through a login that came from (a login the merge moved to it). + public MergeProof Moved(Party holder, Party origin, string provider) => new(Moves.Issue(holder.HeimdallId, origin.Logins[provider]), holder.HeimdallId, provider); + + /// A passkey assertion: a strong proof that speaks for the whole account. + public MergeProof Strong(Party who) => new(Moves.Issue(who.HeimdallId, stepUpMethod: Passkey), who.HeimdallId, "passkey"); + + public async Task InScopeAsync(Func> work) + { + using var scope = Host.Services.CreateScope(); + return await work(scope.ServiceProvider); + } + + public async Task DigestAsync(Party survivor, Party folded) => + await InScopeAsync(async sp => (await sp.GetRequiredService().PlanAsync(survivor.Id, folded.Id, default)).Digest(survivor.Id, folded.Id)); + + public async Task ConfirmAsync(Party survivor, Party folded, MergeProof survivorProof, params MergeProof[] foldedProofs) + { + var digest = await DigestAsync(survivor, folded); + return await InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, digest, survivorProof, foldedProofs), default)); + } + + public async Task ConfirmedAsync(Party survivor, Party folded, MergeProof survivorProof, params MergeProof[] foldedProofs) => + Assert.IsType(await ConfirmAsync(survivor, folded, survivorProof, foldedProofs)).MergeId; + + public Task AdvanceAsync(Guid id) => InScopeAsync(sp => sp.GetRequiredService().AdvanceAsync(id, default)); + + public Task AdvanceDueAsync() => InScopeAsync(sp => sp.GetRequiredService().AdvanceDueAsync(Clock.Now, default)); + + public Task MergeAsync(Guid id) => + InScopeAsync(sp => sp.GetRequiredService().AccountMerges.AsNoTracking().SingleAsync(x => x.Id == id)); + + public Task> JournalAsync(Guid id) => + InScopeAsync(sp => sp.GetRequiredService().AccountMergeEntries.Where(x => x.MergeId == id).ToListAsync()); + + public Task StandingAsync(Party who) => + InScopeAsync(sp => sp.GetRequiredService().StandingAsync(who.Id, default)); + + public Task MergeCountAsync() => InScopeAsync(sp => sp.GetRequiredService().AccountMerges.CountAsync()); + + public async Task MotionAsync(Party author) + { + using var scope = Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var motion = new Motion + { + CreatedByUserAccountId = author.Id, Title = "m", Status = MotionStatus.Open, Kind = MotionKind.Standard, OpensAtUtc = Clock.Now, + ClosesAtUtc = Clock.Now.AddDays(3), CreatedAtUtc = Clock.Now, ApprovalThreshold = 0.5m + }; + db.Motions.Add(motion); + await db.SaveChangesAsync(); + return motion; + } + + public async Task VoteAsync(Motion motion, Party who, VoteChoice choice) + { + using var scope = Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Votes.Add(new Vote { MotionId = motion.Id, UserAccountId = who.Id, Choice = choice, Weight = 1m, CastAtUtc = Clock.Now.AddHours(1) }); + await db.SaveChangesAsync(); + } + + public Task SeenMoveIdsAsync() => + Task.FromResult(Moves.Requests.Select(x => JsonDocument.Parse(x.Body).RootElement.GetProperty("moveId").GetString()!).ToArray()); + } + + // ---- the happy path and the stored move id ---- + + [Fact] + public async Task Two_weak_folded_proofs_and_a_survivor_proof_confirm_and_advance_to_committed_with_a_seven_day_grace_and_one_stored_move() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord", "patreon"); + + var digest = await rig.DigestAsync(survivor, folded); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")); + var confirmed = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.Confirmed, confirmed.State); + Assert.Equal(survivor.Id, confirmed.ConfirmedByUserAccountId); + Assert.Equal(rig.Clock.Now, confirmed.ConfirmedUtc); + Assert.Equal("twitch", confirmed.SurvivorProofProvider); + Assert.Equal("discord,patreon", confirmed.FoldedProofProviders); + Assert.Equal(digest, confirmed.PreviewDigest); + Assert.NotNull(confirmed.SealedProofs); + Assert.Matches("^[0-9a-f]{32}$", confirmed.HeimdallMoveId!); + Assert.Empty(rig.Moves.Requests); // confirm calls no Heimdall + Assert.IsType(await rig.StandingAsync(folded)); + + var done = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Committed, done.State); + Assert.Equal(done.CommittedUtc + AccountMergeLedger.Grace, done.GraceEndsUtc); + var stored = Assert.Single(rig.Moves.Stored); + Assert.Equal(confirmed.HeimdallMoveId, stored.MoveId); + var final = await rig.MergeAsync(id); + Assert.Null(final.SealedProofs); + Assert.Equal("weak", final.ProofFromTier); + Assert.Equal("weak", final.ProofToTier); + Assert.Equal(folded.Logins.Values.Order(), JsonSerializer.Deserialize(final.MovedIdentityIds!)!.Order()); + Assert.IsType(await rig.StandingAsync(folded)); + Assert.Equal(["twitch", "discord", "patreon"], rig.Moves.Logins[survivor.HeimdallId].Select(x => x.Provider)); + } + + [Fact] + public async Task A_vote_on_the_same_open_motion_from_both_accounts_counts_only_the_survivors_after_the_saga_lands() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, survivor, VoteChoice.For); + await rig.VoteAsync(motion, folded, VoteChoice.Against); + + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + Assert.Equal(survivor.Id, (await db.Votes.SingleAsync(x => x.MotionId == motion.Id)).UserAccountId); + var all = await db.Votes.IgnoreQueryFilters().Where(x => x.MotionId == motion.Id).ToListAsync(); + Assert.Equal(id, Assert.Single(all, x => x.UserAccountId == folded.Id).VoidedByMergeId); + return 0; + }); + } + + [Fact] + public async Task Heimdall_sees_the_move_id_stored_on_the_row_and_it_is_not_derived_from_the_merge_id() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + Assert.NotEqual(id.ToString("N"), (await rig.MergeAsync(id)).HeimdallMoveId); + const string overwritten = "overwritten0123456789abcdef0123456789"; + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.AccountMerges.SingleAsync(x => x.Id == id)).HeimdallMoveId = overwritten; + await db.SaveChangesAsync(); + return 0; + }); + + await rig.AdvanceAsync(id); + + Assert.Equal(overwritten, Assert.Single(rig.Moves.Stored).MoveId); + } + + // ---- crashes and lost answers ---- + + [Fact] + public async Task A_lost_answer_leaves_the_merge_confirmed_and_a_fresh_scope_after_the_proofs_expired_settles_it_by_replay() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + rig.Moves.MoveApplyThenFail = HttpStatusCode.InternalServerError; + + var lost = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Confirmed, lost.State); + Assert.Equal(1, lost.Attempts); + Assert.Equal(rig.Clock.Now + TimeSpan.FromSeconds(15), lost.NextAttemptUtc); + Assert.Equal("HTTP 500", lost.LastOutcome); + Assert.Single(rig.Moves.Stored); + rig.Moves.MoveApplyThenFail = null; + rig.Clock.Advance(TimeSpan.FromSeconds(601)); // every proof is stale and the bearers expired by now; Heimdall answers a replay first + + Assert.Equal(1, await rig.AdvanceDueAsync()); + + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + Assert.Single(rig.Moves.Stored); + Assert.Equal(2, (await rig.SeenMoveIdsAsync()).Length); + } + + [Fact] + public async Task A_crash_on_the_save_that_writes_HeimdallMoved_is_resumed_with_the_same_move_id_and_one_stored_move() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + rig.Host.Fault.When = db => db.ChangeTracker.Entries().Any(x => x.Entity.State == AccountMergeState.HeimdallMoved); + + await Assert.ThrowsAsync(() => rig.AdvanceAsync(id)); + rig.Host.Fault.When = null; + Assert.Equal(AccountMergeState.Confirmed, (await rig.MergeAsync(id)).State); + Assert.NotNull((await rig.MergeAsync(id)).SealedProofs); + + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + + var seen = await rig.SeenMoveIdsAsync(); + Assert.Equal(2, seen.Length); + Assert.Single(seen.Distinct()); + Assert.Single(rig.Moves.Stored); + Assert.Equal(seen[0], (await rig.MergeAsync(id)).HeimdallMoveId); + } + + [Fact] + public async Task A_crash_on_the_Committed_save_leaves_HeimdallMoved_and_the_next_advance_commits_once_without_calling_Heimdall_again() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, folded, VoteChoice.For); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + rig.Host.Fault.When = db => db.ChangeTracker.Entries().Any(x => x.Entity.State == AccountMergeState.Committed); + + await Assert.ThrowsAsync(() => rig.AdvanceAsync(id)); + rig.Host.Fault.When = null; + var moved = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.HeimdallMoved, moved.State); + Assert.Null(moved.SealedProofs); + Assert.Empty(await rig.JournalAsync(id)); + + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + + Assert.Single(rig.Moves.Requests); + var journal = await rig.JournalAsync(id); + Assert.Equal(journal.Count, journal.Select(x => x.Seq).Distinct().Count()); + Assert.Single(journal, x => x.Kind == AccountMergeEntryKind.Vote); + } + + [Fact] + public async Task The_sweepers_own_loop_resumes_a_confirmed_merge_after_a_restart_and_finalizes_it_after_the_grace() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + + await RunOneSweepAsync(rig); + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1)); + await RunOneSweepAsync(rig); + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); + } + + /// Starts the production sweeper (its first pass runs at once, before its 30 s timer) and stops it once the pass is over. + private static async Task RunOneSweepAsync(Rig rig) + { + var sweeper = new AccountMergeSweeper(rig.Host.Services.GetRequiredService(), rig.Clock, NullLogger.Instance); + await sweeper.StartAsync(default); + var before = rig.Clock.Now; + var deadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < deadline && sweeper.ExecuteTask is { IsCompleted: false } && await rig.InScopeAsync(async sp => + await sp.GetRequiredService().AccountMerges.AnyAsync(x => + x.State == AccountMergeState.Confirmed || x.State == AccountMergeState.HeimdallMoved || (x.State == AccountMergeState.Committed && x.GraceEndsUtc <= before)))) + { + await Task.Delay(50); + } + + await sweeper.StopAsync(default); + } + + // ---- refusals ---- + + [Theory] + [InlineData("proof_stale")] + [InlineData("identity_not_proved")] + [InlineData("provider_conflict")] + [InlineData("already_moved")] + public async Task A_coded_refusal_fails_the_merge_wipes_the_proofs_and_changes_nothing_in_Bifrost(string code) + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", code == "provider_conflict" ? ["discord"] : ["twitch"]); + var folded = await rig.AccountAsync("folded", "discord", "patreon"); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, folded, VoteChoice.For); + MergeProof[] foldedProofs = code == "identity_not_proved" + ? [rig.Weak(folded, "discord")] // the patreon login is not proved: no partial move + : [rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")]; + var survivorProof = rig.Weak(survivor, code == "provider_conflict" ? "discord" : "twitch"); + if (code == "already_moved") + { + // Heimdall already moved the folded account elsewhere; Bifrost's merge does not know. + var elsewhere = await rig.AccountAsync("elsewhere", "twitch2"); + var move = new HeimdallMoveRequest("preexisting-move-0000", [rig.Strong(folded).AccessToken], rig.Weak(elsewhere, "twitch2").AccessToken); + Assert.IsType(await rig.InScopeAsync(sp => sp.GetRequiredService().MoveAsync(move, default))); + foldedProofs = [rig.Strong(folded)]; + } + + var id = await rig.ConfirmedAsync(survivor, folded, survivorProof, foldedProofs); + if (code == "proof_stale") + { + rig.Clock.Advance(TimeSpan.FromSeconds(400)); + } + + var failed = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Failed, failed.State); + Assert.Equal(code, failed.FailureCode); + Assert.Equal(rig.Clock.Now, failed.FailedUtc); + var stored = await rig.MergeAsync(id); + Assert.Null(stored.SealedProofs); + Assert.Empty(await rig.JournalAsync(id)); + Assert.Null(await rig.StandingAsync(folded)); + await rig.InScopeAsync(async sp => + { + var vote = await sp.GetRequiredService().Votes.SingleAsync(); + Assert.Equal(folded.Id, vote.UserAccountId); + return 0; + }); + Assert.DoesNotContain(rig.Moves.Stored, x => x.MoveId == stored.HeimdallMoveId); + } + + // ---- confirm's consent checks ---- + + [Fact] + public async Task Confirm_refuses_a_third_party_a_changed_plan_a_proof_of_another_account_and_a_blocked_pair_and_writes_no_row() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var stranger = await rig.AccountAsync("stranger", "github"); + var digest = await rig.DigestAsync(survivor, folded); + var good = rig.Weak(survivor, "twitch"); + var foldedProof = rig.Weak(folded, "discord"); + + Task Confirm(Guid actor, string digestSeen, MergeProof survivorProof, params MergeProof[] proofs) => + rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + new ConfirmMergeRequest(actor, survivor.Id, folded.Id, digestSeen, survivorProof, proofs), default)); + + Assert.IsType(await Confirm(stranger.Id, digest, good, foldedProof)); + Assert.IsType(await Confirm(survivor.Id, digest, rig.Weak(stranger, "github"), foldedProof)); + Assert.IsType(await Confirm(survivor.Id, digest, good, rig.Weak(survivor, "twitch"))); + var motion = await rig.MotionAsync(survivor); + await rig.VoteAsync(motion, folded, VoteChoice.For); // the plan the member saw is no longer the plan + Assert.IsType(await Confirm(survivor.Id, digest, good, foldedProof)); + Assert.Equal(0, await rig.MergeCountAsync()); + + var blocked = Assert.IsType(await rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + new ConfirmMergeRequest(survivor.Id, survivor.Id, survivor.Id, "x", good, [good]), default))); + Assert.Contains("same_account", blocked.Codes); + Assert.Equal(0, await rig.MergeCountAsync()); + + // a second confirm of the same fold, after the first + var fresh = await rig.DigestAsync(survivor, folded); + Assert.IsType(await Confirm(survivor.Id, fresh, good, foldedProof)); + var second = Assert.IsType(await Confirm(survivor.Id, fresh, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"))); + Assert.Contains(AccountMergeSaga.MergeInFlight, second.Codes); + Assert.Equal(1, await rig.MergeCountAsync()); + } + + [Fact] + public async Task A_malformed_confirm_throws_before_any_row_and_neither_the_message_nor_the_proof_text_carry_a_token() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + const string canary = "CANARY-bearer-9f3a"; + var proof = new MergeProof(canary, folded.HeimdallId, "discord"); + Assert.DoesNotContain(canary, proof.ToString()); + Assert.DoesNotContain(folded.HeimdallId, proof.ToString()); + var request = new ConfirmMergeRequest(survivor.Id, survivor.Id, folded.Id, "d", new MergeProof(canary, survivor.HeimdallId, "twitch"), [proof]); + Assert.DoesNotContain(canary, request.ToString()); + + var nine = Enumerable.Repeat(proof, 9).ToList(); + var tooMany = await Assert.ThrowsAsync(() => + rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync(request with { FoldedProofs = nine }, default))); + var tooLong = await Assert.ThrowsAsync(() => + rig.InScopeAsync(sp => sp.GetRequiredService().ConfirmAsync( + request with { FoldedProofs = [proof with { AccessToken = canary + new string('x', HeimdallIdentityMoveClient.MaxProofLength) }] }, default))); + + Assert.DoesNotContain(canary, tooMany.ToString()); + Assert.DoesNotContain(canary, tooLong.ToString()); + Assert.Equal(0, await rig.MergeCountAsync()); + } + + // ---- sealing ---- + + [Fact] + public async Task Sealed_proofs_never_hold_a_bearer_in_the_clear_are_wiped_at_each_final_answer_and_no_log_carries_a_bearer_or_an_account() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var survivorProof = rig.Weak(survivor, "twitch"); + var foldedProof = rig.Weak(folded, "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, survivorProof, foldedProof); + var sealedText = (await rig.MergeAsync(id)).SealedProofs!; + Assert.DoesNotContain(survivorProof.AccessToken, sealedText); + Assert.DoesNotContain(foldedProof.AccessToken, sealedText); + Assert.DoesNotContain("bearer", sealedText); + + await rig.AdvanceAsync(id); + Assert.Null((await rig.MergeAsync(id)).SealedProofs); + + var undoProof = rig.Moved(survivor, folded, "discord"); // the moved login now lives on the survivor + Assert.IsType(await RequestUndoAsync(rig, id, survivor, undoProof)); + var requested = (await rig.MergeAsync(id)).SealedProofs!; + Assert.DoesNotContain(undoProof.AccessToken, requested); + await rig.AdvanceAsync(id); + Assert.Null((await rig.MergeAsync(id)).SealedProofs); + + var log = string.Join('\n', rig.Host.Logs.Lines); + foreach (var secret in new[] { survivorProof.AccessToken, foldedProof.AccessToken, undoProof.AccessToken, survivor.HeimdallId, folded.HeimdallId } + .Concat(folded.Logins.Values).Concat(survivor.Logins.Values)) + { + Assert.DoesNotContain(secret, log); + } + + Assert.Contains(id.ToString(), log); + } + + private static Task RequestUndoAsync(Rig rig, Guid id, Party actor, params MergeProof[] proofs) => + rig.InScopeAsync(sp => sp.GetRequiredService().RequestUndoAsync(id, actor.Id, proofs, default)); + + [Fact] + public async Task Unreadable_sealed_proofs_change_no_state_and_retry_in_an_hour_and_the_error_log_names_only_the_merge() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.AccountMerges.SingleAsync(x => x.Id == id)).SealedProofs = "CfDJ8-not-a-protected-payload"; + await db.SaveChangesAsync(); + return 0; + }); + + var stuck = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Confirmed, stuck.State); + Assert.Equal(AccountMergeSaga.ProofsUnreadable, stuck.LastOutcome); + Assert.Equal(rig.Clock.Now + TimeSpan.FromHours(1), stuck.NextAttemptUtc); + Assert.Empty(rig.Moves.Requests); + Assert.Contains(rig.Host.Logs.Lines, x => x.Contains(id.ToString()) && x.Contains("unreadable")); + Assert.Equal(0, await rig.AdvanceDueAsync()); // not due for an hour + } + + [Fact] + public async Task A_receipt_for_another_account_is_recorded_wipes_the_proofs_and_is_never_retried() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.UserAccounts.SingleAsync(x => x.Id == survivor.Id)).HeimdallAccountId = HeimdallShapes.NewAccountId(); // Bifrost's own link drifted + await db.SaveChangesAsync(); + return 0; + }); + + var stuck = await rig.AdvanceAsync(id); + var again = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Confirmed, stuck.State); + Assert.Equal(AccountMergeSaga.ReceiptMismatch, stuck.LastOutcome); + // The answer stays what Heimdall gave: a later sweep does not turn it into an hourly "proofs unreadable" retry. + Assert.Equal(AccountMergeSaga.ReceiptMismatch, again.LastOutcome); + Assert.Null(again.NextAttemptUtc); + Assert.Null((await rig.MergeAsync(id)).SealedProofs); + Assert.Single(rig.Moves.Requests); + Assert.IsType(await rig.StandingAsync(folded)); + } + + // ---- Heimdall down ---- + + [Fact] + public async Task While_Heimdall_is_down_the_merge_stays_confirmed_and_the_folded_account_frozen_then_commits_when_it_answers() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Strong(survivor), rig.Strong(folded)); // strong proofs outlive the outage below + rig.Moves.MoveDown = new HttpRequestException("down"); + + Assert.Equal(1, await rig.AdvanceDueAsync()); + Assert.Equal(0, await rig.AdvanceDueAsync()); // backed off 15 s + rig.Clock.Advance(TimeSpan.FromSeconds(16)); + Assert.Equal(1, await rig.AdvanceDueAsync()); + rig.Clock.Advance(TimeSpan.FromSeconds(61)); + Assert.Equal(1, await rig.AdvanceDueAsync()); + + var down = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.Confirmed, down.State); + Assert.Equal(3, down.Attempts); + Assert.Equal(rig.Clock.Now + TimeSpan.FromMinutes(5), down.NextAttemptUtc); + Assert.IsType(await rig.StandingAsync(folded)); + Assert.Empty(rig.Moves.Stored); + + rig.Moves.MoveDown = null; + rig.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal(1, await rig.AdvanceDueAsync()); + + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + } + + // ---- undo ---- + + [Fact] + public async Task Undo_with_survivor_proofs_of_the_moved_logins_reverses_the_move_and_ends_Undone() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord", "patreon"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord"), rig.Weak(folded, "patreon")); + await rig.AdvanceAsync(id); + var forward = (await rig.MergeAsync(id)).HeimdallMoveId; + + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Moved(survivor, folded, "discord"), rig.Moved(survivor, folded, "patreon"))); + var requested = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.UndoRequested, requested.State); + Assert.Matches("^[0-9a-f]{32}$", requested.HeimdallReverseMoveId!); + Assert.NotEqual(forward, requested.HeimdallReverseMoveId); + Assert.IsType(await rig.StandingAsync(folded)); + + var done = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Undone, done.State); + Assert.NotNull((await rig.MergeAsync(id)).ReversedUtc); + Assert.Equal(2, rig.Moves.Stored.Count); + var reversal = Assert.Single(rig.Moves.Stored, x => x.ReversesMoveId is not null); + Assert.Equal(forward, reversal.ReversesMoveId); + Assert.Equal(requested.HeimdallReverseMoveId, reversal.MoveId); + Assert.Equal(["discord", "patreon"], rig.Moves.Logins[folded.HeimdallId].Select(x => x.Provider).Order()); + Assert.Null(await rig.StandingAsync(folded)); + } + + [Fact] + public async Task A_refused_undo_returns_the_merge_to_Committed_with_the_proofs_wiped_and_the_sweep_finalizes_it_after_the_grace() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + var committed = await rig.AdvanceAsync(id); + rig.Moves.Logins[survivor.HeimdallId].RemoveAll(x => x.Provider == "discord"); // the survivor unlinked a moved login + + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + var back = await rig.AdvanceAsync(id); + + Assert.Equal(AccountMergeState.Committed, back.State); + var stored = await rig.MergeAsync(id); + Assert.Equal("undo_not_reversible", stored.LastOutcome); + Assert.Null(stored.SealedProofs); + Assert.Equal(committed.GraceEndsUtc, stored.GraceEndsUtc); + Assert.IsType(await rig.StandingAsync(folded)); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1)); + await rig.InScopeAsync(async sp => + { + await sp.GetRequiredService().AdvanceDueAsync(rig.Clock.Now, default); + return await sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default); + }); + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); + } + + [Fact] + public async Task Undo_is_requested_only_inside_the_grace_only_by_the_survivor_and_only_from_Committed() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + Assert.IsType(await RequestUndoAsync(rig, Guid.NewGuid(), survivor, rig.Weak(survivor, "twitch"))); + await rig.AdvanceAsync(id); + + Assert.IsType(await RequestUndoAsync(rig, id, folded, rig.Weak(folded, "discord"))); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(folded, "discord"))); + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(id)).State); + + var proof = rig.Weak(survivor, "twitch"); + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromSeconds(1)); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, proof)); // Committed, but the grace ended + await rig.InScopeAsync(sp => sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default)); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, proof)); // Final + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(id)).State); + } + + [Fact] + public async Task A_suspended_folded_account_into_a_memberless_survivor_confirms_on_the_digest_the_member_saw_and_commits() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + db.Memberships.Remove(await db.Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)); // the chat tier holds no membership + (await db.Memberships.SingleAsync(x => x.UserAccountId == folded.Id)).Status = MembershipStatus.Suspended; + await db.SaveChangesAsync(); + return 0; + }); + + // The member is shown a plan and confirms it: the same unchanged world must show the same digest. + Assert.Equal(await rig.DigestAsync(survivor, folded), await rig.DigestAsync(survivor, folded)); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + var carried = await rig.InScopeAsync(sp => sp.GetRequiredService().Memberships.SingleAsync(x => x.UserAccountId == survivor.Id)); + Assert.Equal(MembershipStatus.Suspended, carried.Status); + } + + [Fact] + public async Task Undo_is_refused_at_the_exact_instant_the_grace_ends_and_allowed_a_second_before() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + var ends = (await rig.MergeAsync(id)).GraceEndsUtc!.Value; + + rig.Clock.Advance(ends - rig.Clock.Now); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + rig.Clock.Advance(TimeSpan.FromSeconds(-1)); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Weak(survivor, "twitch"))); + } + + [Fact] + public void The_sealed_proofs_record_prints_no_bearer() + { + var seal = typeof(AccountMergeSaga).GetNestedType("ProofSeal", System.Reflection.BindingFlags.NonPublic)!; + var instance = Activator.CreateInstance(seal, new List { "bearer-folded-secret" }, "bearer-survivor-secret")!; + + var text = instance.ToString()!; + + Assert.DoesNotContain("secret", text); + } + + [Fact] + public async Task An_unknown_answer_to_the_reversal_is_replayed_under_the_same_reverse_id_even_past_the_grace() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + Assert.IsType(await RequestUndoAsync(rig, id, survivor, rig.Moved(survivor, folded, "discord"))); + rig.Moves.MoveApplyThenFail = HttpStatusCode.BadGateway; + + Assert.Equal(AccountMergeState.UndoRequested, (await rig.AdvanceAsync(id)).State); + rig.Moves.MoveApplyThenFail = null; + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromDays(1)); + await rig.AdvanceDueAsync(); + + Assert.Equal(AccountMergeState.Undone, (await rig.MergeAsync(id)).State); + var seen = (await rig.SeenMoveIdsAsync()).Skip(1).ToArray(); + Assert.Equal(2, seen.Length); + Assert.Single(seen.Distinct()); + Assert.Equal(2, rig.Moves.Stored.Count); + } + + // ---- the wiring ---- + + [Fact] + public void The_host_registers_exactly_one_merge_loop_in_production_and_the_saga_beside_the_ledger() + { + var program = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Bifrost.Web", "Program.cs")); + Assert.Single(System.Text.RegularExpressions.Regex.Matches(program, @"AddHostedService<.*Merge")); + Assert.Contains("AddScoped", program); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Bifrost.slnx"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("repo root not found"); + } +} diff --git a/tests/Bifrost.Web.Tests/AccountMergeSettlePageTests.cs b/tests/Bifrost.Web.Tests/AccountMergeSettlePageTests.cs new file mode 100644 index 0000000..5337eb8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeSettlePageTests.cs @@ -0,0 +1,160 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static Bifrost.Web.Tests.AccountMergeSagaTests; +using static Bifrost.Web.Tests.AccountMergeSettleTests; + +namespace Bifrost.Web.Tests; + +/// The operator's page for stuck merges, from browser sessions of the real host: admin only, the stored move id shown as the confirmation, results by closed code. +public sealed class AccountMergeSettlePageTests +{ + private const string Url = "/App/Merges"; + + private sealed class World : IDisposable + { + public Rig Rig { get; } = new(realSessions: true); + + public Party Admin { get; private set; } = null!; + + public string AdminSession { get; private set; } = string.Empty; + + public string MemberSession { get; private set; } = string.Empty; + + public Stuck Forward { get; private set; } = null!; + + public static async Task Make() + { + var world = new World(); + var rig = world.Rig; + world.Admin = await rig.AccountAsync("operator", "github"); + var member = await rig.AccountAsync("member", "github"); + await rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == world.Admin.Id); + membership.IsPlatformAdmin = true; + await db.SaveChangesAsync(); + return 0; + }); + world.AdminSession = await rig.Host.SignInSessionAsync(world.Admin.HeimdallId); + world.MemberSession = await rig.Host.SignInSessionAsync(member.HeimdallId); + world.Forward = await ConfirmedStuckAsync(rig, AccountMergeSaga.ReceiptMismatch); + return world; + } + + public void Dispose() => Rig.Dispose(); + + public Task<(HttpResponseMessage Response, string Html)> Get(string session, string query = "") => MergePage.GetAsync(Rig.Host, session, query, Url); + + public async Task Post(string session, string handler, string mergeId, string moveId, MergePage.Antiforgery? token = null) => + await MergePage.PostAsync(Rig.Host, session, token ?? await MergePage.TokenAsync(Rig.Host, session, HeimdallHost.LinkedAccountsUrl), handler, + new Dictionary { ["mergeId"] = mergeId, ["moveId"] = moveId }, Url); + } + + private static bool Refused(HttpResponseMessage response) => + response.StatusCode == HttpStatusCode.Forbidden || (response.StatusCode == HttpStatusCode.Redirect && (response.Headers.Location?.OriginalString.Contains("/Membership/Status?ReturnUrl=", StringComparison.Ordinal) ?? false)); + + [Fact] + public async Task A_member_who_is_not_a_platform_admin_is_refused_on_the_listing_and_on_both_posts_and_nothing_changes() + { + using var w = await World.Make(); + var merge = await w.Rig.MergeAsync(w.Forward.Id); + var requests = w.Rig.Moves.Requests.Count; + + var (get, html) = await w.Get(w.MemberSession); + Assert.True(Refused(get), $"listing gave {(int)get.StatusCode} {MergePage.Location(get)}"); + Assert.DoesNotContain(merge.HeimdallMoveId!, html); + Assert.True(Refused(await w.Post(w.MemberSession, "FailForward", w.Forward.Id.ToString(), merge.HeimdallMoveId!))); + Assert.True(Refused(await w.Post(w.MemberSession, "AbandonUndo", w.Forward.Id.ToString(), merge.HeimdallMoveId!))); + + var after = await w.Rig.MergeAsync(w.Forward.Id); + Assert.Equal(AccountMergeState.Confirmed, after.State); + Assert.Equal(AccountMergeSaga.ReceiptMismatch, after.LastOutcome); + Assert.Equal(requests, w.Rig.Moves.Requests.Count); + } + + [Fact] + public async Task The_listing_shows_stuck_merges_with_their_stored_move_id_and_no_Heimdall_account_or_provider_user_id() + { + using var w = await World.Make(); + var stuck = w.Forward; + var merge = await w.Rig.MergeAsync(stuck.Id); + // Heimdall unreachable for a long time: listed, though only the three stuck outcomes can be ended. + var slow = await ConfirmedStuckAsync(w.Rig, AccountMergeSaga.MoveIdConflict); + var slowMerge = await w.Rig.MergeAsync(slow.Id); + var waiting = await ConfirmedStuckAsync(w.Rig, "transport"); + await EditMerge(w.Rig, waiting.Id, x => x.Attempts = 8); + var healthy = await ConfirmedStuckAsync(w.Rig, "confirmed"); // one waiting for its first replay: not stuck + await EditMerge(w.Rig, healthy.Id, x => x.Attempts = 1); + var undo = await UndoStuckAsync(w.Rig); + var undoMerge = await w.Rig.MergeAsync(undo.Id); + + var (response, html) = await w.Get(w.AdminSession); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(stuck.Id.ToString(), html); + Assert.Contains(merge.HeimdallMoveId!, html); + Assert.Contains(slow.Id.ToString(), html); + Assert.Contains(slowMerge.HeimdallMoveId!, html); + Assert.Contains(waiting.Id.ToString(), html); + Assert.Contains(undo.Id.ToString(), html); + Assert.Contains(undoMerge.HeimdallReverseMoveId!, html); + Assert.DoesNotContain(undoMerge.HeimdallMoveId!, html); // the leg in flight is the reversal + Assert.DoesNotContain(healthy.Id.ToString(), html); + foreach (var party in new[] { stuck.Survivor, stuck.Folded, slow.Survivor, slow.Folded, undo.Survivor, undo.Folded }) + { + Assert.DoesNotContain(party.HeimdallId, html); + Assert.DoesNotContain(party.Id.ToString(), html); + Assert.DoesNotContain(party.Logins.Values.First(), html); + } + } + + [Fact] + public async Task A_platform_admin_ends_a_stuck_leg_by_typing_its_move_id_and_a_wrong_one_changes_nothing() + { + using var w = await World.Make(); + var merge = await w.Rig.MergeAsync(w.Forward.Id); + var token = await MergePage.TokenAsync(w.Rig.Host, w.AdminSession, Url); + var requests = w.Rig.Moves.Requests.Count; + + var wrong = await w.Post(w.AdminSession, "FailForward", w.Forward.Id.ToString(), "0123456789abcdef0123456789abcdef", token); + Assert.Equal(Url + "?result=wrong_move_id", MergePage.Location(wrong)); + Assert.Equal(AccountMergeState.Confirmed, (await w.Rig.MergeAsync(w.Forward.Id)).State); + + var otherLeg = await w.Post(w.AdminSession, "AbandonUndo", w.Forward.Id.ToString(), merge.HeimdallMoveId!, token); + Assert.Equal(Url + "?result=not_stuck", MergePage.Location(otherLeg)); + + var done = await w.Post(w.AdminSession, "FailForward", w.Forward.Id.ToString(), merge.HeimdallMoveId!, token); + Assert.Equal(Url + "?result=settled", MergePage.Location(done)); + var after = await w.Rig.MergeAsync(w.Forward.Id); + Assert.Equal(AccountMergeState.Failed, after.State); + await w.Rig.InScopeAsync(async sp => + { + var audit = await sp.GetRequiredService().AuditEvents.AsNoTracking().SingleAsync(x => x.EntityId == w.Forward.Id.ToString()); + Assert.Equal(w.Admin.Id, audit.ActorUserAccountId); + return 0; + }); + Assert.Equal(requests, w.Rig.Moves.Requests.Count); // the page calls no Heimdall + + var (_, html) = await w.Get(w.AdminSession, "?result=settled"); + Assert.DoesNotContain(w.Forward.Id.ToString(), html); // no longer stuck, so no longer listed + } + + [Fact] + public async Task A_platform_admin_gives_up_a_stuck_undo_by_typing_the_reverse_move_id() + { + using var w = await World.Make(); + var undo = await UndoStuckAsync(w.Rig); + var merge = await w.Rig.MergeAsync(undo.Id); + + var done = await w.Post(w.AdminSession, "AbandonUndo", undo.Id.ToString(), merge.HeimdallReverseMoveId!, await MergePage.TokenAsync(w.Rig.Host, w.AdminSession, Url)); + + Assert.Equal(Url + "?result=settled", MergePage.Location(done)); + Assert.Equal(AccountMergeState.Committed, (await w.Rig.MergeAsync(undo.Id)).State); + } +} diff --git a/tests/Bifrost.Web.Tests/AccountMergeSettleTests.cs b/tests/Bifrost.Web.Tests/AccountMergeSettleTests.cs new file mode 100644 index 0000000..839cc60 --- /dev/null +++ b/tests/Bifrost.Web.Tests/AccountMergeSettleTests.cs @@ -0,0 +1,292 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using static Bifrost.Web.Tests.AccountMergeSagaTests; + +namespace Bifrost.Web.Tests; + +/// +/// The operator's settle of a stuck merge (ruling merge-stuck-end-only-typed-id): two ends, FailForward and AbandonUndo, only from a +/// stuck leg and only with the leg's stored move id typed back, one audit row, never an adopt. The saga rig of AccountMergeSagaTests. +/// +public sealed class AccountMergeSettleTests +{ + private static readonly Guid Operator = Guid.NewGuid(); + + internal sealed record Stuck(Guid Id, Party Survivor, Party Folded); + + /// A Confirmed merge that is stuck on : the two the saga records itself are reached through the real step, the third is the stored Unknown reason. + internal static async Task ConfirmedStuckAsync(Rig rig, string outcome) + { + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + switch (outcome) + { + case AccountMergeSaga.ReceiptMismatch: + await Edit(rig, x => x.UserAccounts.Single(a => a.Id == survivor.Id).HeimdallAccountId = HeimdallShapes.NewAccountId()); + await rig.AdvanceAsync(id); + break; + case AccountMergeSaga.ProofsUnreadable: + await EditMerge(rig, id, x => x.SealedProofs = "CfDJ8-not-a-protected-payload"); + await rig.AdvanceAsync(id); + break; + default: + await EditMerge(rig, id, x => { x.LastOutcome = outcome; x.Attempts = 3; x.NextAttemptUtc = rig.Clock.Now.AddMinutes(5); }); + break; + } + + Assert.Equal(outcome, (await rig.MergeAsync(id)).LastOutcome); + return new Stuck(id, survivor, folded); + } + + /// A merge in UndoRequested whose reversal got a receipt for another account. + internal static async Task UndoStuckAsync(Rig rig) + { + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + await rig.AdvanceAsync(id); + var undo = await rig.InScopeAsync(sp => sp.GetRequiredService().RequestUndoAsync(id, survivor.Id, [rig.Moved(survivor, folded, "discord")], default)); + Assert.IsType(undo); + await Edit(rig, x => x.UserAccounts.Single(a => a.Id == folded.Id).HeimdallAccountId = HeimdallShapes.NewAccountId()); + await rig.AdvanceAsync(id); + var stuck = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.UndoRequested, stuck.State); + Assert.Equal(AccountMergeSaga.ReceiptMismatch, stuck.LastOutcome); + return new Stuck(id, survivor, folded); + } + + private static Task Edit(Rig rig, Action change) => rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + change(db); + await db.SaveChangesAsync(); + return 0; + }); + + internal static Task EditMerge(Rig rig, Guid id, Action change) => Edit(rig, db => change(db.AccountMerges.Single(x => x.Id == id))); + + private static Task SettleAsync(Rig rig, Guid id, SettleChoice choice, string? typed) => + rig.InScopeAsync(sp => sp.GetRequiredService().SettleStuckAsync(id, choice, typed!, Operator, default)); + + private static Task> AuditAsync(Rig rig, Guid id) => + rig.InScopeAsync(sp => sp.GetRequiredService().AuditEvents.AsNoTracking().Where(x => x.EntityId == id.ToString()).ToListAsync()); + + // ---- FailForward ---- + + [Theory] + [InlineData("receipt_mismatch")] + [InlineData("proofs_unreadable")] + [InlineData("move_id_conflict")] + public async Task FailForward_ends_a_stuck_Confirmed_merge_as_Failed_with_the_proofs_wiped_the_folded_account_free_and_one_audit_row(string outcome) + { + using var rig = new Rig(); + var stuck = await ConfirmedStuckAsync(rig, outcome); + Assert.IsType(await rig.StandingAsync(stuck.Folded)); + var moveId = (await rig.MergeAsync(stuck.Id)).HeimdallMoveId!; + + Assert.IsType(await SettleAsync(rig, stuck.Id, SettleChoice.FailForward, moveId)); + + var merge = await rig.MergeAsync(stuck.Id); + Assert.Equal(AccountMergeState.Failed, merge.State); + Assert.Equal(AccountMergeSaga.OperatorFailed, merge.FailureCode); + Assert.Equal(rig.Clock.Now, merge.FailedUtc); + Assert.Null(merge.SealedProofs); + Assert.Null(merge.NextAttemptUtc); + Assert.Null(await rig.StandingAsync(stuck.Folded)); + Assert.Null(await rig.StandingAsync(stuck.Survivor)); + Assert.Empty(await rig.JournalAsync(stuck.Id)); + Assert.Equal(0, await rig.AdvanceDueAsync()); // the sweeper does not pick a settled merge up again + var audit = Assert.Single(await AuditAsync(rig, stuck.Id)); + Assert.Equal("merge.settled", audit.Action); + Assert.Equal(Operator, audit.ActorUserAccountId); + Assert.Contains("FailForward", audit.Detail); + // The row names the choice and the merge: nothing of either side. + Assert.DoesNotContain(stuck.Survivor.HeimdallId, audit.Detail); + Assert.DoesNotContain(stuck.Folded.HeimdallId, audit.Detail); + Assert.DoesNotContain(moveId, audit.Detail); + Assert.DoesNotContain(stuck.Survivor.Id.ToString(), audit.Detail); + Assert.DoesNotContain(stuck.Folded.Id.ToString(), audit.Detail); + } + + [Fact] + public async Task A_settled_merge_can_be_confirmed_again_for_the_same_pair() + { + using var rig = new Rig(); + var stuck = await ConfirmedStuckAsync(rig, AccountMergeSaga.ReceiptMismatch); + await EditHeimdallBack(rig, stuck); + Assert.IsType(await SettleAsync(rig, stuck.Id, SettleChoice.FailForward, (await rig.MergeAsync(stuck.Id)).HeimdallMoveId)); + + Assert.IsType(await rig.ConfirmAsync(stuck.Survivor, stuck.Folded, rig.Weak(stuck.Survivor, "twitch"), rig.Weak(stuck.Folded, "discord"))); + } + + private static Task EditHeimdallBack(Rig rig, Stuck stuck) => + Edit(rig, db => db.UserAccounts.Single(x => x.Id == stuck.Survivor.Id).HeimdallAccountId = stuck.Survivor.HeimdallId); + + // ---- AbandonUndo ---- + + [Fact] + public async Task AbandonUndo_returns_a_stuck_UndoRequested_merge_to_Committed_with_the_grace_the_proofs_wiped_and_one_audit_row() + { + using var rig = new Rig(); + var stuck = await UndoStuckAsync(rig); + var before = await rig.MergeAsync(stuck.Id); + + Assert.IsType(await SettleAsync(rig, stuck.Id, SettleChoice.AbandonUndo, before.HeimdallReverseMoveId)); + + var merge = await rig.MergeAsync(stuck.Id); + Assert.Equal(AccountMergeState.Committed, merge.State); + Assert.Equal(AccountMergeSaga.UndoOperatorAbandoned, merge.LastOutcome); + Assert.Null(merge.SealedProofs); + Assert.Null(merge.NextAttemptUtc); + Assert.Equal(before.CommittedUtc, merge.CommittedUtc); + Assert.Equal(before.GraceEndsUtc, merge.GraceEndsUtc); + Assert.IsType(await rig.StandingAsync(stuck.Folded)); + Assert.Equal(0, await rig.AdvanceDueAsync()); + var audit = Assert.Single(await AuditAsync(rig, stuck.Id)); + Assert.Equal("merge.settled", audit.Action); + Assert.Contains("AbandonUndo", audit.Detail); + Assert.DoesNotContain(before.HeimdallReverseMoveId!, audit.Detail); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1)); + await rig.InScopeAsync(sp => sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default)); + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(stuck.Id)).State); + } + + // ---- refusals write nothing ---- + + [Fact] + public async Task A_merge_in_any_other_state_or_with_another_LastOutcome_is_NotStuck_and_nothing_is_written() + { + using var rig = new Rig(); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + var moveId = (await rig.MergeAsync(id)).HeimdallMoveId!; + + // Confirmed and healthy ("confirmed"), then waiting on Heimdall ("transport"): not stuck. + Assert.IsType(await SettleAsync(rig, id, SettleChoice.FailForward, moveId)); + await EditMerge(rig, id, x => { x.LastOutcome = "transport"; x.Attempts = 9; }); + Assert.IsType(await SettleAsync(rig, id, SettleChoice.FailForward, moveId)); + var snapshot = await rig.MergeAsync(id); + Assert.Equal(AccountMergeState.Confirmed, snapshot.State); + Assert.NotNull(snapshot.SealedProofs); + + // A stuck outcome does not make the other leg's choice valid. + await EditMerge(rig, id, x => x.LastOutcome = AccountMergeSaga.ReceiptMismatch); + Assert.IsType(await SettleAsync(rig, id, SettleChoice.AbandonUndo, moveId)); + + // Heimdall answered Moved: HeimdallMoved and Committed are never settleable, whatever LastOutcome says. + foreach (var state in new[] { AccountMergeState.HeimdallMoved, AccountMergeState.Committed, AccountMergeState.HeimdallReversed, AccountMergeState.Undone, AccountMergeState.Final, AccountMergeState.Failed }) + { + await EditMerge(rig, id, x => { x.State = state; x.LastOutcome = AccountMergeSaga.ReceiptMismatch; }); + Assert.IsType(await SettleAsync(rig, id, SettleChoice.FailForward, moveId)); + Assert.IsType(await SettleAsync(rig, id, SettleChoice.AbandonUndo, moveId)); + Assert.Equal(state, (await rig.MergeAsync(id)).State); + } + + Assert.IsType(await SettleAsync(rig, Guid.NewGuid(), SettleChoice.FailForward, moveId)); + Assert.Empty(await AuditAsync(rig, id)); + } + + [Fact] + public async Task A_move_id_that_is_not_the_stored_one_of_the_leg_is_WrongMoveId_and_nothing_is_written() + { + using var rig = new Rig(); + var stuck = await ConfirmedStuckAsync(rig, AccountMergeSaga.ReceiptMismatch); + var merge = await rig.MergeAsync(stuck.Id); + + foreach (var typed in new[] { "", " ", merge.HeimdallMoveId!.ToUpperInvariant(), merge.HeimdallMoveId + " ", merge.HeimdallMoveId![..31], stuck.Id.ToString("N"), Guid.NewGuid().ToString("N") }) + { + Assert.IsType(await SettleAsync(rig, stuck.Id, SettleChoice.FailForward, typed)); + } + + Assert.IsType(await SettleAsync(rig, stuck.Id, SettleChoice.FailForward, null)); + var after = await rig.MergeAsync(stuck.Id); + Assert.Equal(AccountMergeState.Confirmed, after.State); + Assert.Equal(AccountMergeSaga.ReceiptMismatch, after.LastOutcome); + Assert.Empty(await AuditAsync(rig, stuck.Id)); + } + + [Fact] + public async Task The_undo_leg_is_confirmed_by_the_reverse_move_id_not_the_forward_one() + { + using var rig = new Rig(); + var stuck = await UndoStuckAsync(rig); + var merge = await rig.MergeAsync(stuck.Id); + + Assert.IsType(await SettleAsync(rig, stuck.Id, SettleChoice.AbandonUndo, merge.HeimdallMoveId)); + + Assert.Equal(AccountMergeState.UndoRequested, (await rig.MergeAsync(stuck.Id)).State); + Assert.Empty(await AuditAsync(rig, stuck.Id)); + } + + // ---- the race ---- + + [Fact] + public async Task A_settle_that_races_AdvanceAsync_and_loses_on_the_State_token_changes_nothing() + { + using var rig = new Rig(); + // Heimdall answered move_id_conflict, so the sweeper keeps replaying: the day its proofs verify, the replay lands. + var stuck = await ConfirmedStuckAsync(rig, AccountMergeSaga.MoveIdConflict); + await EditMerge(rig, stuck.Id, x => x.NextAttemptUtc = null); + var moveId = (await rig.MergeAsync(stuck.Id)).HeimdallMoveId!; + var raced = false; + rig.Host.Fault.When = db => + { + if (raced || !db.ChangeTracker.Entries().Any(x => x.Entity.State == AccountMergeState.Failed)) + { + return false; + } + + raced = true; // the settle has staged Failed and is about to save: the step function moves and commits first + Task.Run(() => rig.AdvanceAsync(stuck.Id)).GetAwaiter().GetResult(); + return false; + }; + + var outcome = await SettleAsync(rig, stuck.Id, SettleChoice.FailForward, moveId); + rig.Host.Fault.When = null; + + Assert.True(raced); + Assert.IsType(outcome); + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(stuck.Id)).State); + Assert.Empty((await rig.MergeAsync(stuck.Id)).FailureCode); + // The audit row rides in the same SaveChanges as the transition, so a real database drops both; the in-memory provider + // applies entries one by one and cannot show that half. + Assert.IsType(await rig.StandingAsync(stuck.Folded)); + } + + // ---- proof bearers are swept by the existing tick ---- + + [Fact] + public async Task The_sweeper_tick_drops_a_bearer_past_its_lifetime_with_no_page_access() + { + using var rig = new Rig(); + var proofs = rig.Host.Services.GetRequiredService(); + var actor = Guid.NewGuid(); + var proof = new MergeProof("BEARER-CANARY", HeimdallShapes.NewAccountId(), "discord"); + Assert.Equal(PutOutcome.Stored, proofs.Put(actor, MergeSide.Self, proof, Guid.NewGuid(), [])); + rig.Clock.Advance(MergeProofs.ProofLifetime + AccountMergeSweeper.Tick); + Assert.Equal(1, HeldActors(proofs)); // nobody has touched it since + + var sweeper = new AccountMergeSweeper(rig.Host.Services.GetRequiredService(), rig.Clock, NullLogger.Instance); + await sweeper.StartAsync(default); + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline && HeldActors(proofs) > 0) + { + await Task.Delay(50); + } + + await sweeper.StopAsync(default); + Assert.Equal(0, HeldActors(proofs)); + } + + private static int HeldActors(MergeProofs proofs) => + ((System.Collections.IDictionary)typeof(MergeProofs).GetField("_held", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(proofs)!).Count; +} diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 1e94198..77b9003 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -23,6 +23,7 @@ + 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/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, 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(() => 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 { 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/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/DeskCredentialTests.cs b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs new file mode 100644 index 0000000..8476c87 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskCredentialTests.cs @@ -0,0 +1,420 @@ +using Bifrost.Web.Data; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Tests; + +// The production desk credential, on real tokens: the test host's own issuer mints them through the code flow, and the +// OpenIddict validation handler (the production IDeskCredential) judges them. Only the door is faked. +public sealed class DeskCredentialTests : IDisposable +{ + private const string DeskClient = "ratatoskr-test"; + private const string DeskRedirect = "https://ratatoskr.example.test/callback"; + private const string DeskSecret = "ratatoskr-test-secret"; + private static readonly Uri Origin = new("https://localhost"); + private static readonly string ListTarget = "/api/desk/gamecult-chat/forks"; + + private readonly DeskWebApplicationFactory factory = new(realCredential: true); + + public void Dispose() => factory.Dispose(); + + [Fact] + public async Task A_valid_desk_read_token_lists_through_the_door_and_the_door_is_asked_once() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null)); + + var reply = await GetAsync(token); + + Assert.Equal(200, reply.Status); + Assert.Equal("list", Assert.Single(factory.Door.Calls).Kind); + } + + [Fact] + public async Task No_token_is_401_and_the_door_is_never_asked() + { + await MemberWithTokenAsync("desk.read"); + + var reply = await GetAsync(null); + + Assert.Equal(401, reply.Status); + Assert.Equal("unauthenticated", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_tampered_token_is_401() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + var middle = token.Length / 2; + var tampered = token[..middle] + (token[middle] == 'A' ? 'B' : 'A') + token[(middle + 1)..]; + + Assert.Equal(401, (await GetAsync(tampered)).Status); + Assert.Equal(401, (await GetAsync(token + "x")).Status); + Assert.Equal(401, (await GetAsync("not-a-token")).Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task An_expired_token_is_401() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + Assert.Equal(200, (await GetAsync(token)).Status); + + factory.Clock.Advance(TimeSpan.FromHours(3)); + + Assert.Equal(401, (await GetAsync(token)).Status); + } + + [Fact] + public async Task A_token_for_another_audience_is_401() + { + var (_, token) = await MemberWithTokenAsync($"desk.read {DeskWebApplicationFactory.OtherAudienceScope}"); + + var reply = await GetAsync(token); + + Assert.Equal(401, reply.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_desk_read_token_cannot_answer_and_the_door_is_never_called() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: Bearer(token)); + + Assert.Equal(403, reply.Status); + Assert.Equal("insufficient_scope", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_bearer_in_the_query_string_or_a_form_body_authenticates_nothing() + { + var (_, token) = await MemberWithTokenAsync("desk.answer"); + DeskReplay.HoldVectorForks(factory.Door); + + var query = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null) + "?access_token=" + token, DeskReplay.AnswerBody(DeskReplay.ChatFork)); + var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null) + "?access_token=" + token, null); + + Assert.Equal(401, query.Status); + Assert.Equal(401, list.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_desk_answer_token_reaches_the_answer_door_but_cannot_list() + { + var (_, token) = await MemberWithTokenAsync("desk.answer"); + DeskReplay.HoldVectorForks(factory.Door); + + var list = await GetAsync(token); + var answer = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(token)); + + Assert.Equal(403, list.Status); + Assert.Equal(200, answer.Status); + Assert.Equal("answer", Assert.Single(factory.Door.Calls).Kind); + } + + [Fact] + public async Task A_desk_token_issues_for_a_member_with_no_training_notice_receipt_and_a_chat_token_does_not() + { + var (client, identity) = await SignedInAsync(); + await RemoveReceiptsAsync(identity, withdrawOnly: false); + + // Chat: sent to the consent page, no code. + var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile"); + var chatFinal = await DriveAsync(client, chat); + Assert.Equal(HttpStatusCode.Redirect, chatFinal.StatusCode); + Assert.DoesNotContain("code=", chatFinal.Headers.Location!.OriginalString); + + // Desk: a code, a token, and the desk answers it. + var request = NewRequest(DeskClient, DeskRedirect, "desk.read"); + var code = await CodeAsync(client, request); + var token = await ExchangeAsync(client, request, code, DeskClient, DeskSecret); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 0, 0, [], null)); + Assert.Equal(200, (await GetAsync(token)).Status); + + // A desk code survives the receipt going between authorize and the exchange; a suspended account still does not. + var later = NewRequest(DeskClient, DeskRedirect, "desk.read"); + var laterCode = await CodeAsync(client, later); + await RemoveReceiptsAsync(identity, withdrawOnly: true); + Assert.False(string.IsNullOrEmpty(await ExchangeAsync(client, later, laterCode, DeskClient, DeskSecret))); + } + + [Fact] + public async Task A_desk_scope_mixed_with_a_chat_scope_does_not_skip_the_training_notice_receipt() + { + var (client, identity) = await SignedInAsync(); + await RemoveReceiptsAsync(identity, withdrawOnly: false); + + var mixed = NewRequest(DeskClient, DeskRedirect, "openid desk.read"); + var final = await DriveAsync(client, mixed); + Assert.Equal(HttpStatusCode.Redirect, final.StatusCode); + Assert.DoesNotContain("code=", final.Headers.Location!.OriginalString); + } + + [Fact] + public async Task The_chat_client_can_neither_ask_for_a_desk_scope_nor_use_its_own_token_on_the_desk() + { + var (client, _) = await SignedInAsync(); + + // Asking for the scope: the issuer refuses, so no code and no token exist for the chat client to carry. + var request = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile desk.read"); + var refused = await DriveAsync(client, request); + Assert.True(refused.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.BadRequest, $"got {(int)refused.StatusCode}"); + var back = refused.Headers.Location?.ToString() ?? await refused.Content.ReadAsStringAsync(); + Assert.Contains("not allowed to use the specified scope", back); + Assert.DoesNotContain("code=", back); + + // The token chat does hold: validly signed by the same issuer, and no use at the desk. + var chat = NewRequest(OidcTestKit.ClientId, OidcTestKit.RedirectUri, "openid profile"); + var chatToken = await ExchangeAsync(client, chat, await CodeAsync(client, chat), OidcTestKit.ClientId, factory.OidcSecret); + Assert.Equal(401, (await GetAsync(chatToken)).Status); + var post = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), configure: Bearer(chatToken)); + Assert.Equal(401, post.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task The_session_cookie_alone_authenticates_nothing_on_the_desk() + { + // A signed-in Active member's real session cookie, sent by hand to the desk: it is no credential there. + using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin }); + var identity = $"member-{Guid.NewGuid():N}"; + using var registered = await RegistrationKit.RegisterAsync(bare, identity); + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + var cookie = string.Join("; ", registered.Headers.GetValues("Set-Cookie").Select(c => c.Split(';')[0])); + Assert.Contains("bifrost", cookie, StringComparison.OrdinalIgnoreCase); + + var list = await factory.SendAsync("GET", ListTarget, configure: http => http.Request.Headers.Cookie = cookie); + var answer = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: http => http.Request.Headers.Cookie = cookie); + + Assert.Equal(401, list.Status); + Assert.Equal(401, answer.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_bearer_token_does_not_authenticate_a_cookie_page() + { + var (_, token) = await MemberWithTokenAsync("desk.read desk.answer"); + using var bare = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false, BaseAddress = Origin }); + + foreach (var path in new[] { "/App", "/App/Members", "/api/sealed-motions" }) + { + using var message = new HttpRequestMessage(HttpMethod.Get, path); + message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await bare.SendAsync(message); + + Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found or HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed, + $"{path} answered {(int)response.StatusCode} to a bearer"); + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + } + + [Fact] + public async Task A_valid_token_whose_account_turns_non_active_is_refused_on_its_next_request_without_reissue() + { + var (identity, token) = await MemberWithTokenAsync("desk.read"); + Assert.Equal(200, (await GetAsync(token)).Status); + + await factory.SetMembershipAsync(identity, MembershipStatus.Suspended); + + var reply = await GetAsync(token); + Assert.Equal(403, reply.Status); + Assert.Equal("forbidden", Code(reply)); + Assert.Single(factory.Door.Calls); // only the first request got to the door + + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + Assert.Equal(200, (await GetAsync(token)).Status); + } + + [Fact] + public async Task A_member_who_is_not_a_decider_reads_no_notes_through_a_real_token_and_ids_pass_as_the_door_sent_them() + { + var (_, token) = await MemberWithTokenAsync("desk.read"); + const string secretAnswerer = "0badc0de-0000-4000-8000-00000000000d"; // not on any decider list, so no card carries it + var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)); + var answer = new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Counted", null, null, null, entry.Card.GetProperty("options_hash").GetString(), "a", "private words", + "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, secretAnswerer, "Steward", "r-1"); + entry = entry with { Record = entry.Record with { Answers = [answer] } }; + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry], null)); + + var reply = await GetAsync(token); + + Assert.Equal(200, reply.Status); + Assert.Contains(secretAnswerer, reply.Text); // Bifrost masks no id; Huginn's door withholds them from non-deciders + Assert.DoesNotContain("private words", reply.Text); + } + + // ---- helpers ---- + + private static Action Bearer(string token) => + http => http.Request.Headers.Authorization = "Bearer " + token; + + private Task GetAsync(string? token) => + factory.SendAsync("GET", ListTarget, configure: token is null ? null : Bearer(token)); + + private static string? Code(DeskReply reply) => (string?)JsonNode.Parse(reply.Body)!["code"]; + + private HttpClient NewClient() => factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin, + }); + + // A registered, Active, agreed member holding the session cookie. + private async Task<(HttpClient Client, string Identity)> SignedInAsync() + { + var client = NewClient(); + var identity = $"member-{Guid.NewGuid():N}"; + using var registered = await RegistrationKit.RegisterAsync(client, identity); + Assert.Equal(HttpStatusCode.OK, registered.StatusCode); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + await SeedDeskClientAsync(); + return (client, identity); + } + + // Leaves the account with no live training-notice receipt: none at all, or each one withdrawn. + private async Task RemoveReceiptsAsync(string identity, bool withdrawOnly) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var id = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity).Id; + var receipts = db.ConsentReceipts.Where(x => x.UserAccountId == id).ToList(); + if (withdrawOnly) + { + receipts.ForEach(r => r.WithdrawnAtUtc = DateTimeOffset.UtcNow); + } + else + { + db.ConsentReceipts.RemoveRange(receipts); + } + + await db.SaveChangesAsync(); + } + + private async Task<(string Identity, string Token)> MemberWithTokenAsync(string scope) + { + var (client, identity) = await SignedInAsync(); + var request = NewRequest(DeskClient, DeskRedirect, scope); + var token = await ExchangeAsync(client, request, await CodeAsync(client, request), DeskClient, DeskSecret); + return (identity, token); + } + + // The test-only desk client: the production seeder creates none (Ratatoskr's redirect and host are a separate decision). + private async Task SeedDeskClientAsync() + { + using var scope = factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + if (await manager.FindByClientIdAsync(DeskClient) is not null) + { + return; + } + + await manager.CreateAsync(new OpenIddictApplicationDescriptor + { + ClientId = DeskClient, + ClientSecret = DeskSecret, + ClientType = ClientTypes.Confidential, + RedirectUris = { new Uri(DeskRedirect) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Prefixes.Scope + Scopes.OpenId, + Permissions.Prefixes.Scope + DeskEndpoints.ReadScope, + Permissions.Prefixes.Scope + DeskEndpoints.AnswerScope, + Permissions.Prefixes.Scope + DeskWebApplicationFactory.OtherAudienceScope, + }, + }); + } + + private sealed record AuthRequest(string ClientId, string RedirectUri, string Verifier, string State, string Path); + + private static AuthRequest NewRequest(string clientId, string redirectUri, string scope) + { + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var challenge = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var path = "/connect/authorize?response_type=code" + + $"&client_id={Uri.EscapeDataString(clientId)}&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&scope={Uri.EscapeDataString(scope)}&state={state}&code_challenge={challenge}&code_challenge_method=S256"; + return new AuthRequest(clientId, redirectUri, verifier, state, path); + } + + // Starts an authorize request and follows the provider's own redirects until the first response that is not one of them. + private static async Task DriveAsync(HttpClient client, AuthRequest request) + { + var response = await client.GetAsync(request.Path); + for (var hops = 0; hops < 4 && IsAuthorizeRedirect(response); hops++) + { + var next = response.Headers.Location!; + response.Dispose(); + response = await client.GetAsync(next.IsAbsoluteUri ? next.PathAndQuery : next.OriginalString); + } + + return response; + } + + private static bool IsAuthorizeRedirect(HttpResponseMessage response) => + response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found && + response.Headers.Location is { } location && + (location.IsAbsoluteUri ? location.AbsolutePath : location.OriginalString).StartsWith("/connect/authorize", StringComparison.Ordinal); + + private static async Task CodeAsync(HttpClient client, AuthRequest request) + { + var final = await DriveAsync(client, request); + Assert.True(final.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect to the client, got {(int)final.StatusCode}"); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(final.Headers.Location!.Query); + Assert.True(query.ContainsKey("code"), final.Headers.Location.ToString()); + return query["code"].ToString(); + } + + private async Task ExchangeAsync(HttpClient client, AuthRequest request, string code, string clientId, string secret) + { + var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token") + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = request.RedirectUri, + ["code_verifier"] = request.Verifier, + }), + }; + message.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{clientId}:{secret}"))); + using var bare = NewClient(); + using var response = await bare.SendAsync(message); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + return body.GetProperty("access_token").GetString()!; + } +} diff --git a/tests/Bifrost.Web.Tests/DeskDocumentTests.cs b/tests/Bifrost.Web.Tests/DeskDocumentTests.cs index f97e3f6..7551f1f 100644 --- a/tests/Bifrost.Web.Tests/DeskDocumentTests.cs +++ b/tests/Bifrost.Web.Tests/DeskDocumentTests.cs @@ -64,12 +64,31 @@ public sealed class DeskDocumentTests public void Cards_round_trip_with_their_spelling() { var rows = DeskVectors.Rows("card", "valid").ToList(); - Assert.Equal(5, rows.Count); + Assert.Equal(7, rows.Count); foreach (var row in rows) { var message = row["message"]!; - var card = message.Deserialize(DeskJson.Options)!; + var card = message.Deserialize(DeskJson.Options); Assert.True(DeskVectors.SameShape(message, Write(card)), (string)row["name"]!); } } + + // The card is opaque: whatever keys, in whatever order, with whatever numbers, leave as they came. + [Fact] + public void An_unknown_card_passes_through_byte_for_byte() + { + const string card = + "{\"order\":7,\"raised_at\":\"2026-11-11T09:00:00Z\",\"title\":\"It's \u00e9\",\"fork_id\":\"x:question:y\"," + + "\"future\":{\"z\":1.50,\"a\":[3,null,1e2],\"big\":12345678901234567890},\"options_hash\":\"ab\",\"leans\":null}"; + var entryJson = + "{\"schemaVersion\":\"gamecult.fork.entry.v0\",\"card\":" + card + + ",\"record\":{\"schemaVersion\":\"gamecult.fork.record.v0\",\"fork_id\":\"x:question:y\",\"options_hash\":\"ab\",\"counted_answer_id\":null,\"answerer_count\":0,\"answers\":[]}}"; + + var entry = JsonSerializer.Deserialize(entryJson, DeskJson.Options)!; + var written = JsonSerializer.Serialize(entry, DeskJson.Options); + Assert.Equal(entryJson, written); + + var result = new AnswerResult(DeskSchemas.AnswerResult, null!, entry.Card, null); + Assert.Contains("\"card\":" + card + ",", JsonSerializer.Serialize(result, DeskJson.Options)); + } } diff --git a/tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs b/tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs new file mode 100644 index 0000000..da8d5bb --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskFeedFixturePinTests.cs @@ -0,0 +1,200 @@ +using System.Security.Cryptography; + +namespace Bifrost.Web.Tests; + +// Huginn's desk-feed golden bytes, vendored byte for byte from crates/huginn-mind/tests/fixtures/desk-feed/ at Huginn +// main 232764bf78fa93c807d2e18b1d77f45c36159be0 (docs/desk-feed-door.md is the contract): 99 files, the +// subdirectory cultlib-d3025d3c/ (fourteen CultLib vectors, unedited) included. The pin is the exact file list and each +// sha256; a changed byte, an extra file or a missing file fails, and the verifier is proved to catch each. +// A silent re-vendor fails here: move the pin on purpose, with the contract and the door's tests. +public sealed class DeskFeedFixturePinTests +{ + public const string HuginnCommit = "232764bf78fa93c807d2e18b1d77f45c36159be0"; + + public static readonly string Directory = Path.Combine(AppContext.BaseDirectory, "Fixtures", "desk-feed"); + + public static readonly IReadOnlyDictionary Sha256 = new Dictionary(StringComparer.Ordinal) + { + [".gitattributes"] = "171872d6445cb1ffef0263b613d1ac318a60398449831d4cc4f772f78a7ebcff", + ["README.md"] = "fd3429ee796ac1c3de9571bc2d09e24f0f91aae48d461f1c8eefa51c6b50a3bc", + ["cultlib-d3025d3c/README.md"] = "b0f6ce43f4c5aa9ae263071b814e2f71445bd50db1d55c6fbcdaf2714166b47a", + ["cultlib-d3025d3c/answer-counted.msgpack"] = "55d69ae3cc7ebbeeae7d501e8641640e115c3fb5aac399ea760588bca343b9e6", + ["cultlib-d3025d3c/answer-refused-stale.msgpack"] = "3183bc9c483f4a625332cda11ef22a9c8560868a7728b77daa63a1960a3cd3d6", + ["cultlib-d3025d3c/answer-retraction.msgpack"] = "89156f41ec3ea54f5d47bd3318913ff013785f4727916875be97785935cb7735", + ["cultlib-d3025d3c/answer-revision.msgpack"] = "19d7289395ae68cbaf6de1c0a1ef6c843211b2caf9462e1d2268cd0883386315", + ["cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack"] = "7d0cc555bf9b6f7c345b0da7c504bcd1ed7b46040ff51d1e57c84b2f56655213", + ["cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack"] = "93f7c875f963a7cfeab2a5980b6d5ce81e6753fc9f3f8bf0d7353f3f388144b7", + ["cultlib-d3025d3c/answer-withheld-answerer.msgpack"] = "b66b4f921cdd374be04af3de34c59e254a5e542027ae8c57f196a3ea3406e427", + ["cultlib-d3025d3c/card-operator-fork.msgpack"] = "56bfdb65ded60b562001d09d0b8da6e092900f404bf5f68f7084dff29bcc34f5", + ["cultlib-d3025d3c/card-raised-at-nil.msgpack"] = "b81bb825790ffe03fabe63c3f8a61c36bd50ca268aa6dff67b5bcfad3957e58c", + ["cultlib-d3025d3c/card-withheld-deciders.msgpack"] = "d13b9b77461cfdf78f943ef56c6aeab852ab6b6f0b355e26c5b361f99c79c7f7", + ["cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack"] = "f7859c7d04d338d6b1c634308b08ae58ae77b0ff459767859a5e40f0af2f16e5", + ["cultlib-d3025d3c/entry-open-counted.msgpack"] = "b51f499e8dd0465cf5df9d68d8fccbe94dd1339fd3157969693dd0d4ecb2549b", + ["cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack"] = "daaa819bfa2612616826ef3217dfcf8d8d8cb71d86547d7106426c779134f827", + ["cultlib-d3025d3c/entry-withheld-open-counted.msgpack"] = "62ccad7a9f41262d6e35d0247315d0bb75d1313b3c9dabe8a99d1aa1b844607f", + ["error.response.frame.bin"] = "fa03f44b82d7c4796f45dd36086a54342dc1de7e660a58cf3ddff4ff59d85a4c", + ["error.response.frame.hex"] = "21c75b6e1d000ddc1a5455a9cc4d6c5b37bd1640c22e1055e96ff85bdff461bb", + ["failure.response.frame.bin"] = "edc2277943334744d3057902800c25bbc913ff8ed336b4612f1a19772ec46dc9", + ["failure.response.frame.hex"] = "b3c262ac506b51529e8063b2d38cc8c0047b431b75fe6ede1a91adebefd014d3", + ["failure.response.payload.hex"] = "13ca85b5868451892746d2531833d394b66d25b5069cd0208169c5fcc94bf350", + ["failure.response.payload.msgpack"] = "4bf7c80eb2d75b9559ab430757a98ebfd424745ed3fc1888d21bc6e41ed99403", + ["list-counts.request.frame.bin"] = "be338fc06f127f9c20f82952708ffe95f8b6b8de7c5098834eb8cb257893637f", + ["list-counts.request.frame.hex"] = "496bbc58292a1d029361bd553eef2ccf786cfa6520b1a3ad97c831893c36cf50", + ["list-counts.request.payload.hex"] = "5ec076a5d8006a0290ad269deffb2bfe45ac7ee8b6a11ea2d4968a6ac8603e4b", + ["list-counts.request.payload.msgpack"] = "52f244c3d704067f79561c8d46f4069ac3f4452a73d916fb92154d28b28ea6c7", + ["list-counts.response.frame.bin"] = "bf51c933fbbaa61934907cb66f5e07ac813de48e849ac80bdf3ba4f75d3952a3", + ["list-counts.response.frame.hex"] = "3f395ae2302d0882fb2456fe94512bba045a1c967f98b4ebf6b306dd5e3d74a3", + ["list-counts.response.payload.hex"] = "9a6f6b0be985d6ff8d313fa9e12ffe6662a59ff7305e574f4bfe8bf9a3ce2291", + ["list-counts.response.payload.msgpack"] = "1f83a7c9ab1ce1af7c245a8f11f54ea67ef273fc2533554db3a1711e3f650c13", + ["list-empty.response.frame.bin"] = "faf3c5008bd50c7f76121a3f27c5c4f944d1c7ad32d8bbd4e67d025c45b3f4f1", + ["list-empty.response.frame.hex"] = "9b0f15bf8948e44f9e43718914f6621402c98c06e0d21bf90640c521ab7f1a45", + ["list-empty.response.payload.hex"] = "d93cd65c1d9f4fc052b9c7f17d16ea6e468a7af296cafd6a31d671ae855d2860", + ["list-empty.response.payload.msgpack"] = "7b1a26f303d3bdd0317d24a0eb4a2d9144ada0a878c927905a1a345efabf3a9f", + ["list-non-decider.request.frame.bin"] = "ebf8664681fcf624e4c6b82eb5e23e4c31b56d0f8a03f1cfbadc766408eaa18e", + ["list-non-decider.request.frame.hex"] = "f0d7f662425ca1c16dea4f5eb78d894898d0dda2ff04f9614663c74620371d26", + ["list-non-decider.request.payload.hex"] = "383122413f2bd276d27c53bdfcac9f65ba704ea2b1e1c18e72b89b164aad2c2d", + ["list-non-decider.request.payload.msgpack"] = "9915249f377a6afe4dda2f007c23e8f2598f759bd9d3991834c9ee162b0d6a5b", + ["list-open-cursor.request.frame.bin"] = "13671e1c83c10350d86b149a86a98b69763b9a3e40eae0024c10f3e70b96ef8b", + ["list-open-cursor.request.frame.hex"] = "e2270f26d122374554855bf60686edbc5d4abfd63374cde08ab479a5f310aa12", + ["list-open-cursor.request.payload.hex"] = "437f4c0cbdc712e9b3f8e1f8666cf8ffe88061cce8d0da3a64e648eb7b6bb27f", + ["list-open-cursor.request.payload.msgpack"] = "05889e90f8143aa1a1375539f6e76593a682413b70cb40a8aaa3c7db2882622a", + ["list-open-non-decider.response.frame.bin"] = "8bf4539f305a9530ac88090bdb2f3a3c851f14f66bd2c69f8009ad4934aec3ad", + ["list-open-non-decider.response.frame.hex"] = "d377e096ce781b0fb86398a58e69ee86205770c298120facac9e739937e88e7d", + ["list-open-non-decider.response.payload.hex"] = "c99b932fb7c19fc28a8fb04f44b80951a55722cbab9c32114ca41c1cd13ef84e", + ["list-open-non-decider.response.payload.msgpack"] = "14616093522771954b34b7414849c2896b0d328c8cf0feac6d8038116d813d8c", + ["list-open.request.frame.bin"] = "2af34627a45d7bfca034b880bf61f5dfd78022a8df425f418c0a084181663903", + ["list-open.request.frame.hex"] = "03f74ba78996b2ccb3fcf5fb712ae1d19f7594099ba04250ee0223c8631b9a24", + ["list-open.request.payload.hex"] = "a58e8583d41d4a04cbd983c6b5a8bdae26105f2eb7f25dfb7d5f86aa81847e93", + ["list-open.request.payload.msgpack"] = "431e000c3f002c254adc57273557c8111e815ca42a8dd4272762d3e7697d7f0a", + ["list-open.response.frame.bin"] = "82d7b9bd8a5d3a98f2d5780b3cb59fb75b598a333069a03243c1ec83faa6f099", + ["list-open.response.frame.hex"] = "ebc75b6ae8d16b1cbe0bece1ed3fa32d0ead42fc39ca939e542a8fa45e1dadee", + ["list-open.response.payload.hex"] = "618918c8020e7bf673758a7e6a58b78469449838b287402bd383a7d1258adc57", + ["list-open.response.payload.msgpack"] = "f16c74f6340054e40d0d0862381da717a1fa651a67f3250b7bd555cea2834a6e", + ["list-response-too-large.response.frame.bin"] = "b95218e48c1f8e4162f515b97a85c324b9ccfe2b3216780ed0eca1d2e2066f19", + ["list-response-too-large.response.frame.hex"] = "b4bedb2c59b1362e5059caf6dbea63952b8fe9c47b681da3825422bc677f8632", + ["list-response-too-large.response.payload.hex"] = "4f5f18915e0e536c387e659eb2021570a5b0bc5e0f530521ca226dc687d6780d", + ["list-response-too-large.response.payload.msgpack"] = "9749f0dc7a3ec69aba7f45ba62ef61332461747cf42b188b84998da7bbd4d5d9", + ["list-too-large.response.frame.bin"] = "1210fb82189a23d7ee4eb3851d4b83013f4f2a2298533734df348156097b5c60", + ["list-too-large.response.frame.hex"] = "aad762b1e1deea39330223eb9ea7ebd2bb0fdd3326e259383b90846c1310456f", + ["list-too-large.response.payload.hex"] = "880ae2c51454a3d6b06d2e827e44b6990365d8cfa304b55e25eb898641117762", + ["list-too-large.response.payload.msgpack"] = "710d145c1ded7e7d02343342becd61e4520da979580f13dbebe6f0eda5aabec6", + ["read-non-decider.request.frame.bin"] = "3f2729eca79e3c21feebc763f22b5ee86803b333a22b39d5cc1635e2fae245b6", + ["read-non-decider.request.frame.hex"] = "9dce0664c145dff66d52e2c2d21cf12b187884392908f031a310534716646aa5", + ["read-non-decider.request.payload.hex"] = "32d77b81c10bc8accbc31a667d2ad631bf5e3404a485d3d3bed3889c50571f8e", + ["read-non-decider.request.payload.msgpack"] = "c2ec1ecb06123fbb6292ed7a409035f7cc58408885886259131226a8c0aabc08", + ["read-non-decider.response.frame.bin"] = "15da06225cdd950bf0fe61e49a7ba18d6117b073fdaa6b9732f902564e47d574", + ["read-non-decider.response.frame.hex"] = "9cdb0efaaf52f8234ea4ad9c357d563f134cedc8dc7f94ae1b297a30670dd013", + ["read-non-decider.response.payload.hex"] = "0ae37b5f6920c4a41f4f8fe7c45f03c90ec74e384991920811273a0ef4250f9c", + ["read-non-decider.response.payload.msgpack"] = "41e947355eac70e948633986bc19506fef5c2f92d7fc4242a1783dc1aa688890", + ["read-too-large.response.frame.bin"] = "5995c03914851ea95aaff4b75fa3ebaee4f19a2c7ce101eab85b7475515690bd", + ["read-too-large.response.frame.hex"] = "b150aa192bf3fa415b5175f8b9f43731c5b73c2a2153c87da2dc3a4b6d986ee2", + ["read-too-large.response.payload.hex"] = "18b7ad6c617ee1d9a3aa726a012e9a3af8ca9c14b8af774d082cf17879c85b90", + ["read-too-large.response.payload.msgpack"] = "4a9135ede7d6588ba65e49b372b99d5da956fab9c693f1aa018e0ebe1406246a", + ["read-unknown.response.frame.bin"] = "a62293ff79c0bbe626d020140f4c8cbe3f62a87ac0f730f3ba3c2a7acd3d49a5", + ["read-unknown.response.frame.hex"] = "97ab11a04a2d62917186b23666ed26ef09df220ffb1c3e188d919985085733e2", + ["read-unknown.response.payload.hex"] = "ca8d4d1fa4d40915b6e062767967e62343b360ce669801da96d99509e24787fb", + ["read-unknown.response.payload.msgpack"] = "0d4fe47159db88b5048bc1f42a0e72857b568eac48572777a0c896ee94f7c208", + ["read.request.frame.bin"] = "3a9bc16ce4ce706e8c839bf4cfe65d3e2fdf759992555e68be6fc7bcc15653ea", + ["read.request.frame.hex"] = "3c3d9540c4af789d230b7108f98d5d5f3dd9b291ff618064ddc374eb2a7bb082", + ["read.request.payload.hex"] = "48aed263254eac7bc156c1af1f2ee210a08dda2b453a5be2dc17083ab73800b2", + ["read.request.payload.msgpack"] = "c5447f462247703af4329e071bc0ac14dce1cb94a63c85517400791d022263ca", + ["read.response.frame.bin"] = "34acacf58f3bd10e86bad6b10f3f14b6a263709b54ff81873af7dcfaf3d95a06", + ["read.response.frame.hex"] = "600848ae27be452d9a7845c5d822c8eabc1df07b163071d35cc38f6f2ec330bb", + ["read.response.payload.hex"] = "c3322aa9989a47b9b255cd8d20d6026a159c6b9a8a5ee22f135407bf96cfd2d3", + ["read.response.payload.msgpack"] = "79fb2f9dd3fde3335bca5cc4337abcf0d7e195afc341352087e6432b7f5ae0a7", + ["record-whole.payload.hex"] = "871ee32813f46656aa372f6d791d36f885e83194294d3a9fa1fcb906cab73eb4", + ["record-whole.payload.msgpack"] = "e85e420e7450cfecc698268299b1a26e1ed256d0420143cbad74f80b5a52731d", + ["record-withheld.payload.hex"] = "a0e7afd9131387cc25d5a2ff5b5f12d546ddd38dfe2bc0d74d3c55a4439048d6", + ["record-withheld.payload.msgpack"] = "aff799fe787258c35e658cf4d6dea33b95938c47f2d4e72fcdf194f0b537bfed", + ["refused-unit.response.frame.bin"] = "4f86943a6947dd3c6041ee7d1b52d3f73697604dd224ca5dcf5b66064b0de1f1", + ["refused-unit.response.frame.hex"] = "f25f35911d242d6fdb7ac4e1ecb3fe01901aa4b816d957104bde6f21b29a1bed", + ["refused-unit.response.payload.hex"] = "858d49c8c66ce1b9c32160e2aecb320a8c00166728c80f5a02d62ec90b4f410d", + ["refused-unit.response.payload.msgpack"] = "f2eedbece230b164adf1897c3a047317a4e3074b2d5fd4c5abef3fbb98336c32", + ["unavailable.response.frame.bin"] = "0cb241568bb20d0ccf62f337f4daa01ca3dd82cedd476fb4c1f9e7dc3272cabe", + ["unavailable.response.frame.hex"] = "86f0e5bb2d3e0ea39ae2f0dbc2c1b62e5a77a35bffc6f4adfa87d280d718bf44", + ["unavailable.response.payload.hex"] = "5a8e7d6e3cd7abf3649941d6b55a511d025c2baf2d8dde08e505e57e9cb4fd22", + ["unavailable.response.payload.msgpack"] = "dfa613abaf5f23e6aff5bcb0a59b3a1784f2fbf6d12cbdfa82f10e80963f0a4e", + }; + + // Every way the directory differs from the pin, by relative path (forward slashes). + public static List Problems(string directory) + { + var present = new Dictionary(StringComparer.Ordinal); + foreach (var file in new DirectoryInfo(directory).EnumerateFiles("*", SearchOption.AllDirectories)) + { + var name = Path.GetRelativePath(directory, file.FullName).Replace('\\', '/'); + present[name] = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file.FullName))).ToLowerInvariant(); + } + + var problems = new List(); + problems.AddRange(Sha256.Keys.Except(present.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"missing: {n}")); + problems.AddRange(present.Keys.Except(Sha256.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).Select(n => $"extra: {n}")); + problems.AddRange(Sha256.Where(p => present.TryGetValue(p.Key, out var actual) && actual != p.Value).Select(p => $"changed: {p.Key}")); + return problems; + } + + [Fact] + public void Fixtures_are_huginn_232764bf_byte_for_byte() + { + Assert.Equal(99, Sha256.Count); + Assert.True(Problems(Directory).Count == 0, + $"not Huginn {HuginnCommit}:crates/huginn-mind/tests/fixtures/desk-feed/: {string.Join("; ", Problems(Directory))}. " + + "Re-vendor from that commit or move the pin on purpose."); + } + + private static string Copy() + { + var copy = System.IO.Directory.CreateTempSubdirectory("pin").FullName; + foreach (var file in new DirectoryInfo(Directory).EnumerateFiles("*", SearchOption.AllDirectories)) + { + var to = Path.Combine(copy, Path.GetRelativePath(Directory, file.FullName)); + System.IO.Directory.CreateDirectory(Path.GetDirectoryName(to)!); + file.CopyTo(to); + } + + return copy; + } + + [Theory] + [InlineData("list-open.request.frame.bin", "changed")] + [InlineData("cultlib-d3025d3c/entry-open-counted.msgpack", "changed")] + [InlineData("README.md", "changed")] + public void A_changed_byte_fails_the_pin(string name, string kind) + { + var copy = Copy(); + try + { + var path = Path.Combine(copy, name); + var bytes = File.ReadAllBytes(path); + bytes[^1] ^= 1; + File.WriteAllBytes(path, bytes); + + Assert.Equal([$"{kind}: {name}"], Problems(copy)); + } + finally + { + System.IO.Directory.Delete(copy, true); + } + } + + [Fact] + public void An_extra_or_missing_file_fails_the_pin_at_either_level() + { + var copy = Copy(); + try + { + File.WriteAllText(Path.Combine(copy, "extra.hex"), "00\n"); + File.WriteAllText(Path.Combine(copy, "cultlib-d3025d3c", "extra.msgpack"), "x"); + File.Delete(Path.Combine(copy, "read.request.frame.hex")); + File.Delete(Path.Combine(copy, "cultlib-d3025d3c", "answer-counted.msgpack")); + + Assert.Equal( + ["missing: cultlib-d3025d3c/answer-counted.msgpack", "missing: read.request.frame.hex", "extra: cultlib-d3025d3c/extra.msgpack", "extra: extra.hex"], + Problems(copy)); + } + finally + { + System.IO.Directory.Delete(copy, true); + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs index 693950a..53863b6 100644 --- a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -32,7 +32,7 @@ public sealed class DeskOptionsTests return options; } - private static void Validate(Dictionary values, string environment = "Testing") + private static void Validate(Dictionary values, string environment = "Testing", bool oidc = true) { var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); new StartupConfigurationValidator( @@ -45,7 +45,8 @@ public sealed class DeskOptionsTests Options.Create(new BifrostHostOptions()), Options.Create(new ConceptVoteOptions()), Options.Create(Bind(values)), - Options.Create(new RegistrationOptions())).Validate(); + Options.Create(new RegistrationOptions()), + oidc ? new OidcIssuerOptions() : null).Validate(); } private static void Refused(Dictionary values, string expectedFragment) @@ -65,6 +66,15 @@ public sealed class DeskOptionsTests Validate(Good()); } + [Fact] + public void A_desk_without_the_oidc_issuer_stops_startup_and_says_why() + { + var failure = Assert.Throws(() => Validate(Good(), oidc: false)); + Assert.Contains("Bifrost:Oidc must be configured when the Desk section exists", failure.Message); + + Validate([], oidc: false); // no desk, no need of an issuer + } + [Fact] public void No_desk_section_is_not_configured_and_passes() { diff --git a/tests/Bifrost.Web.Tests/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs index 14e4af9..027d7da 100644 --- a/tests/Bifrost.Web.Tests/DeskReadTests.cs +++ b/tests/Bifrost.Web.Tests/DeskReadTests.cs @@ -78,33 +78,6 @@ public sealed class DeskReadTests Assert.Equal(200, bare.Status); } - [Fact] - public async Task List_scenarios_pass_through() - { - var steps = 0; - foreach (var scenario in DeskVectors.Rows("transport", "listScenarios")) - { - using var factory = new DeskWebApplicationFactory(); - await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); - foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject())) - { - var request = step["request"]!; - var response = step["response"]!.AsObject(); - var list = response.Deserialize(DeskJson.Options)!; - factory.Door.ListScript = (_, _) => new DeskListOutcome.Listed(list); - var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}"; - - var reply = await factory.SendAsync("GET", $"/api/desk/{(string)request["mind"]!}/forks" + query, null, ReadToken(factory)); - - Assert.Equal(200, reply.Status); - Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), (string)scenario["name"]!); - steps++; - } - } - - Assert.Equal(9, steps); - } - [Fact] public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused() { @@ -374,7 +347,13 @@ public sealed class DeskReadTests private static DeskAnswerDocument Doc(string id, string outcome, string? of, string? retracts, string? note) => new( DeskSchemas.DeskAnswer, id, DeskReplay.ChatFork, outcome, of, retracts, null, "h", note == null ? null : "a", note, - "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, DeskWebApplicationFactory.Alice, "Steward", "r-" + id); + "2026-11-12T19:00:00Z", "2026-11-12T19:00:01Z", false, AnswererOf(id), "Steward", "r-" + id); + + // Four different accounts, so a mask that kept any one of them, or told two apart, is seen. + private static readonly string[] Answerers = + [DeskWebApplicationFactory.Alice, DeskWebApplicationFactory.Steward, DeskWebApplicationFactory.Member, "0badc0de-0000-4000-8000-00000000000d"]; + + private static string AnswererOf(string answerId) => Answerers[int.Parse(answerId[1..]) - 1]; // Counted, Revision, Retraction and Refused documents, every one carrying the decider's note. private static ForkEntry NotedEntry() @@ -424,6 +403,9 @@ public sealed class DeskReadTests Assert.DoesNotContain("decider's own words", list); Assert.False(AnyNote(JsonNode.Parse(read))); Assert.False(AnyNote(JsonNode.Parse(list))); + // Bifrost masks no id: what the door sent for the answerers is what the reader gets (the door withholds ids from non-deciders). + var readAnswerers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"]); + Assert.Equal(NotedEntry().Record.Answers.Select(x => x.Answerer), readAnswerers); // Outcomes and cards stay; the key stays written as nil, so the body is still the contract's shape. var expected = JsonSerializer.SerializeToNode(NotedEntry() with { @@ -436,6 +418,22 @@ public sealed class DeskReadTests Assert.Equal(2, JsonNode.Parse(list)!["forks"]!.AsArray().Count); } + [Fact] + public async Task Bifrost_relays_exactly_the_entries_the_door_returns_to_any_reader() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var forks = Enumerable.Range(1, 7).Select(i => DeskReplay.Entry(DeskReplay.Card("gamecult-chat:question:q" + i))).ToArray(); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 7, 0, forks, null)); + + foreach (var account in new[] { DeskWebApplicationFactory.Member, DeskWebApplicationFactory.Alice }) + { + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, factory.Token(account, "bifrost-desk", "desk.read")); + Assert.Equal(forks.Select(f => f.Card.GetProperty("fork_id").GetString()), JsonNode.Parse(reply.Body)!["forks"]!.AsArray().Select(f => (string?)f!["card"]!["fork_id"])); + } + } + [Fact] public async Task A_decider_reads_and_lists_the_notes() { @@ -445,6 +443,7 @@ public sealed class DeskReadTests var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Alice); Assert.Contains(Secret + " 3", read); + Assert.Equal(Answerers, JsonNode.Parse(read)!["record"]!["answers"]!.AsArray().Select(a => (string?)a!["answerer"])); Assert.Equal(8, JsonNode.Parse(list)!["forks"]!.AsArray().Sum(f => f!["record"]!["answers"]!.AsArray().Count(a => a!["note"] is not null))); Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), JsonSerializer.SerializeToNode(NotedEntry(), DeskJson.Options))); } diff --git a/tests/Bifrost.Web.Tests/DeskReaderViewTests.cs b/tests/Bifrost.Web.Tests/DeskReaderViewTests.cs new file mode 100644 index 0000000..2d7d2d3 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskReaderViewTests.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// CultLib d3025d3c's reader views (append-only on 3a51bbab), as Bifrost meets them: the 18-key card, DE0012 (raised_at, decider_count) stays opaque, +// the record is typed with answerer_count, and answerer is nil in a non-decider's view. Entries, records, list pages and +// stored answers are replayed from their wire bytes through the real door in DeskVectorWireTests. +public sealed class DeskReaderViewTests +{ + private static readonly string[] CardKeys = + [ + "schemaVersion", "fork_id", "title", "context", "options", "recommendation", "depends", "leans", "flag", "raised_in", + "raised_at", "deciders", "decider_count", "status", "ruling", "ruled_from", "order", "options_hash", + ]; + + [Fact] + public void Valid_cards_are_the_17_key_card_with_both_views_and_pass_through_unchanged() + { + var rows = DeskVectors.Rows("card", "valid").ToList(); + Assert.Equal(7, rows.Count); + foreach (var row in rows) + { + var message = row["message"]!.AsObject(); + Assert.Equal(CardKeys, message.Select(p => p.Key)); + Assert.StartsWith("DE0012", (string)row["messagePackHex"]!, StringComparison.Ordinal); + Assert.True(message["raised_at"] is null || message["raised_at"]!.GetValueKind() == JsonValueKind.String); + var count = (int)message["decider_count"]!; + Assert.InRange(count, 1, 64); + var deciders = message["deciders"]!.AsArray(); + Assert.True(deciders.Count == 0 || deciders.Count == count, (string)row["name"]!); + + var card = message.Deserialize(DeskJson.Options); + Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(card, DeskJson.Options)), (string)row["name"]!); + } + + Assert.Contains(rows, r => r["message"]!["raised_at"] is null); + Assert.Contains(rows, r => r["message"]!["deciders"]!.AsArray().Count == 0 && (string)r["name"]! == "withheld_deciders"); + } + + [Fact] + public void The_options_hash_vectors_are_the_15_and_Bifrost_computes_no_hash() + { + Assert.Equal(15, DeskVectors.Array("optionsHash").Count); + Assert.Equal(9, DeskVectors.Array("bodyDigests").Count); + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk"))) + { + directory = directory.Parent; + } + + Assert.NotNull(directory); + foreach (var file in Directory.GetFiles(Path.Combine(directory.FullName, "src", "Bifrost.Web", "Features", "Desk"), "*.cs")) + { + var text = File.ReadAllText(file); + Assert.DoesNotContain("System.Security.Cryptography", text, StringComparison.Ordinal); + Assert.DoesNotContain("SHA256", text, StringComparison.Ordinal); + } + } + + [Fact] + public void Stored_answers_round_trip_and_every_variant_carries_a_nil_answerer_with_the_key_written() + { + var rows = DeskVectors.Rows("deskAnswer", "valid").ToList(); + Assert.Equal(8, rows.Count); + Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer"); + Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision"); + Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction"); + var outcomes = new HashSet(); + foreach (var row in rows) + { + var message = row["message"]!.AsObject(); + Assert.StartsWith("DE0010", (string)row["messagePackHex"]!, StringComparison.Ordinal); + outcomes.Add((string)message["outcome"]!); + Assert.True(DeskVectors.SameShape(message, JsonSerializer.SerializeToNode(message.Deserialize(DeskJson.Options), DeskJson.Options)), (string)row["name"]!); + + var withheld = JsonNode.Parse(message.ToJsonString())!.AsObject(); + withheld["answerer"] = null; + var back = JsonSerializer.SerializeToNode(withheld.Deserialize(DeskJson.Options), DeskJson.Options)!.AsObject(); + Assert.True(back.ContainsKey("answerer")); + Assert.Null(back["answerer"]); + Assert.True(DeskVectors.SameShape(withheld, back), (string)row["name"]!); + } + + Assert.Superset(new HashSet { "Counted", "Revision", "Retraction", "Refused" }, outcomes); + } +} diff --git a/tests/Bifrost.Web.Tests/DeskScenarioTests.cs b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs index 9955bdb..dca4862 100644 --- a/tests/Bifrost.Web.Tests/DeskScenarioTests.cs +++ b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs @@ -22,7 +22,7 @@ public sealed class DeskScenarioTests { using var factory = new DeskWebApplicationFactory(); var door = factory.Door; - door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize(DeskJson.Options)!); + door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(scenario["card"]!.Deserialize(DeskJson.Options)); var scenarioName = (string)scenario["name"]!; foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject())) @@ -36,7 +36,7 @@ public sealed class DeskScenarioTests if (kind == "card") { - door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize(DeskJson.Options)!); + door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize(DeskJson.Options)); continue; } @@ -155,7 +155,7 @@ public sealed class DeskScenarioTests var answer = message["answer"]!.Deserialize(DeskJson.Options)!; var result = message.Deserialize(DeskJson.Options)!; factory.Door.Forks[(Socket, answer.ForkId)] = DeskReplay.Entry( - message["card"] is { } card ? card.Deserialize(DeskJson.Options)! : DeskReplay.Card(answer.ForkId)); + message["card"] is { } card ? card.Deserialize(DeskJson.Options) : DeskReplay.Card(answer.ForkId)); factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null }); await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); diff --git a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs index e1e5364..b0da228 100644 --- a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs +++ b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs @@ -36,13 +36,14 @@ public sealed class DeskSurfaceTests } [Fact] - public void A_configured_desk_without_its_ports_stops_startup() + public void A_configured_desk_with_no_door_supplied_gets_the_production_unix_socket_door() { using var factory = new DeskWebApplicationFactory(registerPorts: false); - var failure = Assert.ThrowsAny(() => factory.CreateClient()); + factory.CreateClient(); - Assert.Contains("IDeskCredential", failure.ToString()); + // The credential is production wiring (OpenIddict validation) and so is the door; a test supplies a fake only by replacing it. + Assert.IsType(factory.Services.GetRequiredService()); } [Fact] diff --git a/tests/Bifrost.Web.Tests/DeskVectorCensusTests.cs b/tests/Bifrost.Web.Tests/DeskVectorCensusTests.cs new file mode 100644 index 0000000..1ed5e46 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskVectorCensusTests.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using System.Text.Json.Nodes; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// Every section of the vendored vectors file (each top-level key, and each key under an object section down to depth 3) is +// classified here: replayed by a named test of Bifrost's, or not judged by Bifrost because the rule belongs to CultLib's +// validators and Huginn's door. A re-pin that adds a section fails until someone classifies it; a section that goes +// fails too. A replayed section names the test that replays it, and the test must exist. +public sealed class DeskVectorCensusTests +{ + private const string Wire = nameof(DeskVectorWireTests); + + // path -> "Class.Method" of a test that replays it. + private static readonly Dictionary Replayed = new(StringComparer.Ordinal) + { + ["card.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_card_leaves_the_door_inside_an_entry_unchanged)}", + ["request.valid"] = "DeskBodyShapeTests.Valid_request_vectors_pass", + ["request.invalid"] = "DeskBodyShapeTests.Invalid_request_vectors_refuse_with_their_code", + ["deskAnswer.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included)}", + ["results"] = "DeskScenarioTests.Answer_results_pass_through_unchanged", + ["doorRefusals"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors", + ["unavailable"] = "DeskDocumentTests.Refusal_and_unavailable_documents_match_vectors", + ["scenarios"] = "DeskScenarioTests.Every_scenario_answer_step_replays_through_http", + ["transport.listRequest.valid"] = "DeskReadTests.Valid_list_queries_reach_the_door_as_typed_requests", + ["transport.listRequest.invalid"] = "DeskReadTests.List_and_read_request_invalid_vectors", + ["transport.entry.valid"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_valid_entry_leaves_a_read_unchanged_in_both_views)}", + ["transport.entry.invalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract)}", + ["transport.listInvalid"] = $"{Wire}.{nameof(DeskVectorWireTests.The_door_refuses_a_negative_count_and_judges_no_other_list_rule)}", + ["transport.listScenarios"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message)}", + ["transport.listShortPages"] = $"{Wire}.{nameof(DeskVectorWireTests.Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader)}", + ["transport.readTooLarge"] = "DeskReadTests.A_read_past_the_cap_follows_the_vectors", + ["transport.answerTooLarge"] = "DeskAnswerTests.A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed", + ["transport.doorFaults"] = "DeskReadTests.Door_faults_follow_the_vectors_per_route", + ["transport.mindRoutes.minds"] = "DeskMindRoutesTests.Every_vector_segment_resolves_or_misses", + ["transport.mindRoutes.forks"] = "DeskMindRouteTests.Vector_route_cases", + ["transport.mindRoutes.cases"] = "DeskMindRouteTests.Vector_route_cases", + ["transport.mindRoutes.sameResponse"] = "DeskMindRouteTests.Same_response_pairs_are_byte_equal", + ["transport.authCases"] = "DeskAuthCaseTests.Vector_auth_cases", + }; + + // path -> why Bifrost does not judge it. + private static readonly Dictionary NotJudged = new(StringComparer.Ordinal) + { + ["provenance"] = "metadata text, not a vector", + ["optionsHash"] = "Bifrost computes no hash of a card (DeskReaderViewTests pins the 15 and that the Desk source holds no SHA256); CultLib's validators and Huginn's door own the options hash", + ["bodyDigests"] = "the body digest behind request_id_conflict is the receiver's (Huginn's door); Bifrost forwards the body as written (DeskReaderViewTests pins the 9)", + ["card.invalid"] = "the card is an opaque map to Bifrost; every card rule (R-card, depends, options, deciders, hash) is CultLib's validators' and Huginn's door's", + ["deskAnswer.invalid"] = "outcome, code and target pairing are CultLib's and Huginn's; the door judges only the closed 16-key typed shape (all twelve have it; DeskVectorWireTests passes them through)", + ["resultsInvalid"] = "R-answer-result (the sender's own answer carries their account) is Huginn's door's rule; DeskVectorWireTests reads the three as any answer", + ["rulingCites"] = "which answer a ruling may cite is a rule of CultLib's validators and the ruling's author; Bifrost forwards answers and rules on nothing", + ["transport.readRequest.valid"] = "the read request is the route's own path segments (mind, fork id); the golden read.request frame pins what the door sends", + ["transport.readRequest.invalid"] = "an empty fork id or mind is a routing miss before any request exists (DeskReadTests, DeskMindRoutesTests)", + }; + + private static IEnumerable Sections(JsonNode node, string path, int depth) + { + if (node is JsonObject map && depth < 3) + { + foreach (var (key, value) in map) + { + foreach (var section in Sections(value!, path.Length == 0 ? key : path + "." + key, depth + 1)) + { + yield return section; + } + } + } + else + { + yield return path; + } + } + + [Fact] + public void Every_section_of_the_vendored_vectors_is_classified_replayed_or_not_judged_and_no_section_is_both() + { + var sections = Sections(DeskVectors.Root, string.Empty, 0).Order(StringComparer.Ordinal).ToList(); + + Assert.Empty(Replayed.Keys.Intersect(NotJudged.Keys)); + Assert.Equal(sections, Replayed.Keys.Concat(NotJudged.Keys).Order(StringComparer.Ordinal).ToList()); + Assert.Contains("transport.entry.invalid", sections); + Assert.Contains("resultsInvalid", sections); + } + + [Fact] + public void Every_replayed_section_names_a_test_that_exists() + { + var assembly = typeof(DeskVectorCensusTests).Assembly; + foreach (var (section, test) in Replayed) + { + var (className, method) = (test[..test.IndexOf('.', StringComparison.Ordinal)], test[(test.IndexOf('.', StringComparison.Ordinal) + 1)..]); + var type = assembly.GetType("Bifrost.Web.Tests." + className); + Assert.True(type is not null, $"{section}: no test class {className}"); + var found = type.GetMethods(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(m => m.Name == method); + Assert.True(found is not null && found.GetCustomAttributes().Any(), $"{section}: no test {test}"); + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs b/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs index 3ed7df6..64e7df2 100644 --- a/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs +++ b/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs @@ -6,8 +6,9 @@ namespace Bifrost.Web.Tests; public sealed class DeskVectorPinTests { [Fact] - public void Vectors_are_cultlib_de8a062a() + public void Vectors_are_cultlib_d3025d3c() { + Assert.StartsWith("d3025d3c", DeskVectors.SourceCommit, StringComparison.Ordinal); var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(DeskVectors.Path))).ToLowerInvariant(); Assert.True( diff --git a/tests/Bifrost.Web.Tests/DeskVectorWireTests.cs b/tests/Bifrost.Web.Tests/DeskVectorWireTests.cs new file mode 100644 index 0000000..95a69cd --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskVectorWireTests.cs @@ -0,0 +1,236 @@ +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +// CultLib's vectors, replayed from their WIRE form (responseHex / messagePackHex) through the real UnixSocketDeskDoor on +// a socket double, then compared with the vector's `message`: same keys in the same order, integers unchanged +// (compared as JSON text; the vector side is parsed JSON, so an escaped apostrophe cannot differ). Only what Huginn +// sends is served here. The refusals, 503 documents and edge-authored results in the vectors are Bifrost's own +// output and are replayed on the edge (DeskReadTests, DeskAnswerTests, DeskDocumentTests). +// +// What the door judges of a reply (the single owner is the wire walker's Closed): every contract document is a closed +// map with every key present and typed; the card inside an entry is an opaque map. What it does not judge: every +// rule a schema cannot say (R-card, R-record, R-entry, R-list, R-answer-result, option and hash rules, ordering, +// the reader-view agreement between deciders and answerers). Those belong to CultLib's validators and to Huginn's door; +// Bifrost holds no audience rule and masks nothing it cannot read. +public sealed class DeskVectorWireTests +{ + private static string Mind(JsonNode request) => (string)request["mind"]!; + + private static IEnumerable<(string Name, JsonNode Request, JsonNode Response, string Hex)> ListSteps() + { + foreach (var scenario in DeskVectors.Rows("transport", "listScenarios").Concat(DeskVectors.Rows("transport", "listShortPages"))) + { + foreach (var step in scenario["steps"]!.AsArray().Select(s => s!.AsObject()).Where(s => s.ContainsKey("responseHex"))) + { + yield return ((string)scenario["name"]!, step["request"]!, step["response"]!, (string)step["responseHex"]!); + } + } + } + + [Fact] + public async Task Every_list_page_in_the_vectors_leaves_the_door_as_the_vectors_message() + { + var steps = 0; + foreach (var (name, request, response, hex) in ListSteps()) + { + var list = await DeskServe.ListAsync(Convert.FromHexString(hex), Mind(request)); + + Assert.True(DeskServe.Text(response) == DeskServe.Text(list), name); + steps++; + } + + Assert.Equal(16, steps); + } + + [Fact] + public async Task Every_list_page_in_the_vectors_leaves_the_edge_as_the_vectors_message_for_its_reader() + { + var steps = 0; + foreach (var (name, request, response, hex) in ListSteps()) + { + var scenario = DeskVectors.Rows("transport", "listScenarios").FirstOrDefault(s => (string)s["name"]! == name); + var nonDecider = scenario is not null && scenario.ContainsKey("twin"); + using var huginn = DeskServe.Socket(DeskServe.Forks(Convert.FromHexString(hex))); + using var factory = new DeskWebApplicationFactory(DeskServe.ConfigurationOn(Mind(request), huginn.Path), fakeDoor: false); + var reader = nonDecider ? DeskWebApplicationFactory.Member : DeskWebApplicationFactory.Alice; + await factory.EnsureAccountAsync(reader, true); + var query = $"?status={(string?)request["status"]}&cursor={(string?)request["cursor"]}&limit={(int)request["limit"]!}"; + + var reply = await factory.SendAsync("GET", $"/api/desk/{Mind(request)}/forks" + query, null, factory.Token(reader, "bifrost-desk", "desk.read")); + + // The edge's one change for a non-decider: every note nil, the key kept. + var expected = JsonNode.Parse(response.ToJsonString())!; + if (nonDecider) + { + foreach (var answer in expected["forks"]!.AsArray().SelectMany(f => f!["record"]!["answers"]!.AsArray())) + { + answer!["note"] = null; + } + } + + Assert.Equal(200, reply.Status); + Assert.True(DeskServe.Text(expected) == DeskServe.Text(JsonNode.Parse(reply.Body)!), name); + steps++; + } + + Assert.Equal(16, steps); + } + + [Fact] + public async Task Every_valid_entry_leaves_a_read_unchanged_in_both_views() + { + var rows = DeskVectors.Rows("transport", "entry", "valid").ToList(); + Assert.Equal(8, rows.Count); + foreach (var row in rows) + { + var entry = await DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!)); + + Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), (string)row["name"]!); + } + } + + [Fact] + public async Task Every_valid_card_leaves_the_door_inside_an_entry_unchanged() + { + var rows = DeskVectors.Rows("card", "valid").ToList(); + Assert.Equal(7, rows.Count); + foreach (var row in rows) + { + var card = row["message"]!; + var record = DeskServe.FromJson(JsonNode.Parse($$"""{"schemaVersion":"gamecult.fork.record.v0","fork_id":"{{(string)card["fork_id"]!}}","options_hash":"{{(string)card["options_hash"]!}}","counted_answer_id":null,"answerer_count":0,"answers":[]}""")!); + + var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(Convert.FromHexString((string)row["messagePackHex"]!), record)); + + Assert.True(DeskServe.Text(card) == DeskServe.Text(entry.Card), (string)row["name"]!); + } + } + + [Fact] + public async Task Every_scenario_record_leaves_the_door_unchanged_the_6_key_record() + { + var count = 0; + foreach (var scenario in DeskVectors.Rows("scenarios")) + { + var hex = (string)scenario["recordHex"]!; + Assert.StartsWith("86", hex, StringComparison.Ordinal); + + var entry = await DeskServe.ReadAsync(DeskServe.EntryOf(DeskServe.FromJson(scenario["card"]!), Convert.FromHexString(hex))); + + Assert.True(DeskServe.Text(scenario["record"]!) == DeskServe.Text(entry.Record), (string)scenario["name"]!); + count++; + } + + Assert.Equal(63, count); + } + + [Fact] + public async Task Every_valid_stored_answer_leaves_an_answered_reply_unchanged_the_withheld_variants_included() + { + var rows = DeskVectors.Rows("deskAnswer", "valid").ToList(); + Assert.Equal(8, rows.Count); + foreach (var row in rows) + { + var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!)); + + Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!); + } + + Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_revision" && (string)r["message"]!["outcome"]! == "Revision" && r["message"]!["answerer"] is null); + Assert.Contains(rows, r => (string)r["name"]! == "withheld_answerer_retraction" && (string)r["message"]!["outcome"]! == "Retraction" && r["message"]!["answerer"] is null); + } + + // The entry negatives on the wire. The door refuses the four that break its closed-map typing: an entry without its + // record, and a missing, negative or nil answerer_count (never a count Bifrost may invent). The other fourteen are + // rules a schema cannot say (R-record, R-entry, the options-hash and derivation rules; the two new ones pin a + // withheld card beside a named Retraction and a count over the answers): CultLib's validators and Huginn's door + // judge them, Bifrost reads a closed map and passes it, and a change to either side shows here. + private static readonly HashSet EntryNegativesTheDoorRefuses = + ["entry_without_record", "answerer_count_missing", "answerer_count_negative", "answerer_count_null"]; + + [Fact] + public async Task The_door_refuses_the_entry_negatives_that_break_its_typing_and_judges_no_rule_of_the_contract() + { + var rows = DeskVectors.Rows("transport", "entry", "invalid").ToList(); + Assert.Equal(18, rows.Count); + Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_beside_a_named_retraction"); + Assert.Contains(rows, r => (string)r["name"]! == "withheld_revision_and_retraction_count_over_the_answers"); + foreach (var row in rows) + { + var name = (string)row["name"]!; + var served = Task.Run(() => DeskServe.ReadAsync(Convert.FromHexString((string)row["messagePackHex"]!))); + + if (EntryNegativesTheDoorRefuses.Contains(name)) + { + await Assert.ThrowsAsync(() => served); + } + else + { + var entry = await served; + Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(entry), name); + } + } + } + + // The list negatives on the wire. The door refuses count_negative (a count is an unsigned integer in its closed typing). + // The others are rules a schema cannot say or text grammars the door does not hold: a cursor is c and a stamp is s + // (Bifrost passes both on, opaque), a page is one reader's view (R-list), and an order is unique among a Mind's cards. + // CultLib's validators and Huginn's door judge them. + [Fact] + public async Task The_door_refuses_a_negative_count_and_judges_no_other_list_rule() + { + var rows = DeskVectors.Rows("transport", "listInvalid").ToList(); + Assert.Equal(5, rows.Count); + foreach (var row in rows) + { + var name = (string)row["name"]!; + var served = Task.Run(() => DeskServe.ListAsync(Convert.FromHexString((string)row["messagePackHex"]!))); + + if (name == "count_negative") + { + await Assert.ThrowsAsync(() => served); + } + else + { + Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(await served), name); + } + } + } + + // The stored-answer negatives and the answer_result negatives. The door judges that an answer is a closed 16-key map with + // every key typed; all twelve deskAnswer negatives have the 16 keys, so none is refused here. They are rules (outcome + // and code pairing, targets, case, and "no_answerer": a nil answerer on a Counted answer is a withheld view only a + // reader can tell from a fault) that the contract's validators and Huginn's door own. A MISSING answerer key is the + // door's fault, pinned in UnixSocketDeskDoorWireTests. The three resultsInvalid vectors are R-answer-result (the + // sender's own answer carries their account, never nil): Huginn's rule; for Bifrost a nil answerer in an Answered + // reply is a string-or-nil key like any other. Whether the edge should add that check is not decided here. + [Fact] + public async Task The_door_judges_no_deskAnswer_rule_of_the_contract_only_its_closed_typed_shape() + { + var rows = DeskVectors.Rows("deskAnswer", "invalid").ToList(); + Assert.Equal(12, rows.Count); + foreach (var row in rows) + { + var answer = await DeskServe.AnswerAsync(Convert.FromHexString((string)row["messagePackHex"]!)); + + Assert.True(DeskServe.Text(row["message"]!) == DeskServe.Text(answer), (string)row["name"]!); + } + } + + [Fact] + public async Task The_three_answer_result_negatives_carry_an_answer_the_door_reads_as_any_answer() + { + var rows = DeskVectors.Rows("resultsInvalid").ToList(); + Assert.Equal(3, rows.Count); + foreach (var row in rows) + { + var answer = row["message"]!["answer"]!; + var served = await DeskServe.AnswerAsync(DeskServe.FromJson(answer)); + + Assert.Null(served.Answerer); + Assert.True(DeskServe.Text(answer) == DeskServe.Text(served), (string)row["name"]!); + } + } +} diff --git a/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs new file mode 100644 index 0000000..c856c8f --- /dev/null +++ b/tests/Bifrost.Web.Tests/FakeHeimdallMoverTests.cs @@ -0,0 +1,168 @@ +using System.Net; +using System.Text; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +/// +/// The fake mover is the spec the client is tested against, so it must refuse what Heimdall 4fe367a refuses, with the same status and code, in the +/// same order (app.ts moveIdentitiesRoute and its schema, identity-move.ts decideMove). Each case sends a raw body to the fake. +/// +public sealed class FakeHeimdallMoverTests +{ + private static string Id() => "move-" + Guid.NewGuid().ToString("N"); + + private static string Account() => HeimdallShapes.NewAccountId(); + + private static async Task<(HttpStatusCode Status, string Error)> Post(HeimdallHost host, string json) + { + using var client = new HttpClient(host.Heimdall, disposeHandler: false) { BaseAddress = new Uri("https://heimdall.test") }; + client.DefaultRequestHeaders.Add("x-heimdall-app-secret", HeimdallHost.AppSecret); + using var response = await client.PostAsync("/v1/apps/bifrost/identity-moves", new StringContent(json, Encoding.UTF8, "application/json")); + var body = await response.Content.ReadAsStringAsync(); + var error = System.Text.Json.JsonDocument.Parse(body).RootElement.TryGetProperty("error", out var e) ? e.GetString()! : ""; + return (response.StatusCode, error); + } + + private static string Move(string moveId, string[] from, string? to = null, string[]? ids = null, string? reverses = null) => + System.Text.Json.JsonSerializer.Serialize(new Dictionary + { + ["moveId"] = moveId, + ["fromProofs"] = from, + ["toProof"] = to, + ["identityIds"] = ids, + ["reversesMoveId"] = reverses + }.Where(x => x.Value is not null).ToDictionary()); + + [Fact] + public async Task A_body_the_routes_schema_rejects_is_a_400_and_moves_nothing() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + var forward = Id(); + Assert.Equal(HttpStatusCode.OK, (await Post(host, Move(forward, [moves.Issue(s, login, "bifrost-passkey")], moves.Issue(d)))).Status); + var (s2, d2) = (Account(), Account()); + var login2 = moves.AddLogin(s2, "patreon"); + moves.AddLogin(d2, "twitch"); + var strong = () => moves.Issue(s2, login2, "bifrost-passkey"); + + var bad = new[] + { + Move(Id(), [moves.Issue(d)], moves.Issue(s), reverses: forward), + Move(Id(), [strong()]), + Move(Id(), [strong()], reverses: "x"), + Move(Id(), [strong()], moves.Issue(d2), [login2, login2]), + Move(Id(), [""], "x"), + Move(Id(), [strong()], "x", [""]), + Move(Id(), [strong()], "x", []), + Move(Id(), [moves.Issue(d)], ids: [login], reverses: forward), + Move(Id(), [], "x"), + Move(Id(), Enumerable.Repeat("p", 9).ToArray(), "x"), + Move(Id(), [new string('p', 8193)], "x"), + Move("short", [strong()], "x"), + Move(Id() + "\n", [strong()], "x"), + Move(new string('a', 65), [strong()], "x"), + Move(Id(), [strong()], reverses: Id() + "\n"), + """{"moveId":"move-0123456789abcdef","fromProofs":"p","toProof":"x"}""", + "[]" + }; + foreach (var json in bad) + { + Assert.Equal((HttpStatusCode.BadRequest, "invalid_request"), await Post(host, json)); + } + + Assert.Single(moves.Stored); + Assert.Single(moves.Logins[s2]); + } + + [Fact] + public async Task A_source_holding_no_login_is_404_identity_not_found_not_an_empty_receipt() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + moves.AddLogin(d, "twitch"); + + var answer = await Post(host, Move(Id(), [moves.Issue(s, Guid.NewGuid().ToString(), "bifrost-passkey")], moves.Issue(d))); + + Assert.Equal((HttpStatusCode.NotFound, "identity_not_found"), answer); + } + + [Fact] + public async Task An_unheld_login_is_404_before_coverage_is_judged() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + + var answer = await Post(host, Move(Id(), [moves.Issue(s, login)], moves.Issue(d), [login, Guid.NewGuid().ToString()])); + + Assert.Equal((HttpStatusCode.NotFound, "identity_not_found"), answer); + } + + [Fact] + public async Task A_proof_that_covers_no_login_at_all_is_403_identity_not_proved_before_the_store_looks() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + moves.AddLogin(d, "twitch"); + + // No auth_identity_id and no passkey: nothing is covered, even though the source holds no login either. + Assert.Equal((HttpStatusCode.Forbidden, "identity_not_proved"), await Post(host, Move(Id(), [moves.Issue(s)], moves.Issue(d)))); + } + + [Fact] + public async Task A_future_dated_proof_proves_nothing() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + + Assert.Equal((HttpStatusCode.Forbidden, "proof_stale"), await Post(host, Move(Id(), [moves.Issue(s, login, authAgo: -60)], moves.Issue(d)))); + } + + [Fact] + public async Task A_replay_whose_proofs_are_not_signed_for_the_stored_accounts_is_409_move_id_conflict_not_proof_invalid() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d) = (Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + var id = Id(); + var to = moves.Issue(d); + Assert.Equal(HttpStatusCode.OK, (await Post(host, Move(id, [moves.Issue(s, login)], to))).Status); + + Assert.Equal((HttpStatusCode.Conflict, "move_id_conflict"), await Post(host, Move(id, ["forged-bearer"], to))); + Assert.Equal((HttpStatusCode.Conflict, "move_id_conflict"), await Post(host, Move(id, [moves.Issue(d)], to))); + Assert.Equal((HttpStatusCode.Conflict, "move_id_conflict"), await Post(host, Move(id, [moves.Issue(s)], reverses: Id()))); + Assert.Equal(HttpStatusCode.OK, (await Post(host, Move(id, [moves.Issue(s)], to))).Status); + } + + [Fact] + public async Task A_reversal_by_the_wrong_account_or_of_a_reversed_move_is_409_not_reversible_in_Heimdalls_order() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (s, d, other) = (Account(), Account(), Account()); + var login = moves.AddLogin(s, "discord"); + moves.AddLogin(d, "twitch"); + moves.AddLogin(other, "google"); + var forward = Id(); + await Post(host, Move(forward, [moves.Issue(s, login, "bifrost-passkey")], moves.Issue(d))); + + // The wrong account proves the reversal: not_reversible only after its proof was judged live. + Assert.Equal((HttpStatusCode.Forbidden, "proof_invalid"), await Post(host, Move(Id(), ["unknown"], reverses: forward))); + Assert.Equal((HttpStatusCode.Conflict, "not_reversible"), await Post(host, Move(Id(), [moves.Issue(other, stepUpMethod: "bifrost-passkey")], reverses: forward))); + Assert.Equal((HttpStatusCode.OK, ""), await Post(host, Move(Id(), [moves.Issue(d, login, "bifrost-passkey")], reverses: forward))); + Assert.Equal((HttpStatusCode.Conflict, "not_reversible"), await Post(host, Move(Id(), [moves.Issue(d, stepUpMethod: "bifrost-passkey")], reverses: forward))); + } +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/.gitattributes b/tests/Bifrost.Web.Tests/Fixtures/.gitattributes new file mode 100644 index 0000000..04ad83b --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/.gitattributes @@ -0,0 +1,3 @@ +fork-card-vectors.json text eol=lf +desk-feed/**/*.md text eol=lf +desk-feed/*.md text eol=lf diff --git a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs index aeaf092..6feea08 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,14 @@ 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("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/Fixtures/desk-feed/.gitattributes b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/.gitattributes new file mode 100644 index 0000000..0f9afd5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/.gitattributes @@ -0,0 +1,3 @@ +*.bin binary +*.msgpack binary +*.hex text eol=lf diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md new file mode 100644 index 0000000..fc47d17 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/README.md @@ -0,0 +1,50 @@ +# Desk feed golden fixtures + +The bytes on a Mind's desk socket for a list or a read. The contract is `docs/desk-feed-door.md`. +`crates/huginn-mind/tests/desk_feed_fixtures.rs` writes these files with Huginn's own encoders and +checks them on every `cargo test -p huginn-mind --test desk_feed_fixtures`. Set +`HUGINN_WRITE_DESK_FIXTURES=1` to rewrite them. Do not edit them by hand. + +Each case has up to four files: + +- `.frame.bin` and `.frame.hex`: the whole frame on the socket (u32 big-endian length, + then the CultNet message). +- `.payload.msgpack` and `.payload.hex`: the base64-decoded `payload` of an operation + message (named MessagePack). The `record-*` cases are bare payload fragments with no frame. + +Hex is lowercase with one trailing newline. The fork id is a question Eureka's mind holds. The +record's answers, notes and hash are synthetic. + +**The listing gate is not live.** On `main` every showable question goes to every reader. Do not +put a Mind that holds private questions behind a live receiver before `cut-desk-feed-listing` +lands (see `docs/desk-feed-door.md`). + +| case | direction | content | +|---|---|---| +| `list-open.request` | Bifrost to Huginn | `Forks`, status open, no cursor, limit 50 | +| `list-open-cursor.request` | Bifrost to Huginn | `Forks`, status open, cursor `c42`, limit 50 | +| `list-counts.request` | Bifrost to Huginn | `Forks`, status open, limit 0 (counts and stamp only) | +| `read.request` | Bifrost to Huginn | `Fork` for one fork id | +| `list-non-decider.request` | Bifrost to Huginn | `Forks` as above, from a subject who is not on the call's deciders | +| `read-non-decider.request` | Bifrost to Huginn | `Fork`, from a subject who is not on the call's deciders | +| `list-open.response` | Huginn to Bifrost | a decider's page of one open fork: card with `raised_at` a string, `decider_count` 2 and both ids, record with `answerer_count` 3 and every `answerer` named | +| `list-open-non-decider.response` | Huginn to Bifrost | the same page for a non-decider: `deciders` empty, `decider_count` 2, every `answerer` nil, `answerer_count` 3; no account id anywhere in its bytes | +| `read.response` | Huginn to Bifrost | a decider's read of one fork, `raised_at` nil | +| `read-non-decider.response` | Huginn to Bifrost | the same read for a non-decider, `raised_at` nil, no account id | +| `list-empty.response` | Huginn to Bifrost | an empty Mind, or nothing listed to this reader: counts 0, `forks: []` | +| `list-counts.response` | Huginn to Bifrost | the limit-0 answer: open 3, recorded 5, `forks: []` | +| `read-unknown.response` | Huginn to Bifrost | `NotStored` `fork_unknown` (Bifrost: 404) | +| `list-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large` naming the cursor to continue (Bifrost: 422) | +| `read-too-large.response` | Huginn to Bifrost | `NotStored` `fork_too_large`, read form: names the fork, no order or cursor (Bifrost: 422) | +| `list-response-too-large.response` | Huginn to Bifrost | `Refused` `ResponseTooLarge` (Bifrost: 503) | +| `refused-unit.response` | Huginn to Bifrost | `Refused` with a unit variant: a bare string `"MissingIdentity"` (shape only; Bifrost: 503) | +| `unavailable.response` | Huginn to Bifrost | `Refused` `Unavailable` (Bifrost: 503 for this Mind) | +| `failure.response` | Huginn to Bifrost | operation failure, `payloadSchema` `gamecult.cultnet.operation_failure.v1` (Bifrost: 503) | +| `error.response` | Huginn to Bifrost | CultNet `Error` message, no payload (Bifrost: 503) | +| `record-whole` | fragment | `gamecult.fork.record.v0` in a decider's view: all four answer variants (Counted, Revision, Refused, Retraction) by three accounts, answerer ids and notes whole, `answerer_count` 3 (6 keys) | +| `record-withheld` | fragment | the same record in a non-decider's view: every `answerer` nil, notes whole, `answerer_count` 3 | + +The reader-count shapes (`raised_at`, `decider_count`, `answerer_count`, nullable `answerer`) are +also CultLib's own bytes, vendored unedited in `cultlib-d3025d3c/` (see its README for the pinned +commit and each vector). Huginn's types decode every one of them and re-encode it byte for byte; +the fixtures above are what Huginn's encoders write for the same shapes. diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/README.md b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/README.md new file mode 100644 index 0000000..2a96359 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/README.md @@ -0,0 +1,28 @@ +# CultLib desk-feed vectors, vendored + +Source: `GameCult/CultLib` at commit `d3025d3c19674803ad32eb2335461b8cbc22bd28` (#118, on +`3a51bbab` #117, on `af914253` #115), file `contracts/cultnet/governance/fork-card-vectors.json`. Each `.msgpack` file is +the vector's `messagePackHex` decoded to bytes, nothing else changed. Do not edit them or rewrite them +from Huginn's encoders: they are the reference Huginn's types are checked against (see +`docs/desk-feed-door.md`). To re-pin, take the new commit's vectors and update the sha here, in +`docs/desk-feed-door.md` and in `CULTLIB_SHA` in `desk_feed_fixtures.rs`. + +| file | upstream vector | what it pins | +|---|---|---| +| `card-operator-fork` | `card.valid` `operator_fork` | 18 keys (`DE 00 12`), `raised_at` a string, a decider's `deciders` and `decider_count` 1 | +| `card-raised-at-nil` | `card.valid` `raised_at_nil` | `raised_at` nil, key kept | +| `card-withheld-deciders` | `card.valid` `withheld_deciders` | empty `deciders`, `decider_count` 2 | +| `answer-counted` | `deskAnswer.valid` `counted` | 16 keys (`DE 00 10`), `answerer` a string | +| `answer-revision` | `deskAnswer.valid` `revision` | a `Revision`, `answerer` a string | +| `answer-retraction` | `deskAnswer.valid` `retraction` | a `Retraction`, `answerer` a string | +| `answer-withheld-answerer` | `deskAnswer.valid` `withheld_answerer` | `answerer` nil | +| `answer-withheld-answerer-revision` | `deskAnswer.valid` `withheld_answerer_revision` | a `Revision`, `answerer` nil | +| `answer-withheld-answerer-retraction` | `deskAnswer.valid` `withheld_answerer_retraction` | a `Retraction`, `answerer` nil | +| `answer-refused-stale` | `deskAnswer.valid` `refused_stale` | a `Refused` answer, `answerer` a string | +| `entry-open-counted` | `transport.entry.valid` `open_counted` | decider view: entry `83`, record `86`, `answerer_count` 1 | +| `entry-withheld-open-counted` | `transport.entry.valid` `withheld_open_counted` | non-decider twin: `deciders` empty, `answerer` nil, count 1 | +| `entry-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `closed_with_ruling_and_a_refusal` | decider view, a refusal among the answers, `answerer_count` 2 | +| `entry-withheld-closed-with-ruling-and-a-refusal` | `transport.entry.valid` `withheld_closed_with_ruling_and_a_refusal` | its non-decider twin, count 2 | + +The test `desk_feed_fixtures.rs` checks each file's SHA-256, decodes it with a decoder of its own +(not `rmp_serde`), then decodes it into Huginn's real type and re-encodes it, byte for byte. diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-counted.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-counted.msgpack new file mode 100644 index 0000000..332a979 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-counted.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-refused-stale.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-refused-stale.msgpack new file mode 100644 index 0000000..75fc7fd Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-refused-stale.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-retraction.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-retraction.msgpack new file mode 100644 index 0000000..7f2fb22 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-retraction.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-revision.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-revision.msgpack new file mode 100644 index 0000000..aaff18f Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-revision.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack new file mode 100644 index 0000000..4c676cf Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer-retraction.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack new file mode 100644 index 0000000..b1e5fcf Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer-revision.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer.msgpack new file mode 100644 index 0000000..5074793 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/answer-withheld-answerer.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-operator-fork.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-operator-fork.msgpack new file mode 100644 index 0000000..325e668 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-operator-fork.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-raised-at-nil.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-raised-at-nil.msgpack new file mode 100644 index 0000000..a5e848e Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-raised-at-nil.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-withheld-deciders.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-withheld-deciders.msgpack new file mode 100644 index 0000000..922be4e Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/card-withheld-deciders.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack new file mode 100644 index 0000000..93e6b2d Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-closed-with-ruling-and-a-refusal.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-open-counted.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-open-counted.msgpack new file mode 100644 index 0000000..eb86045 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-open-counted.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack new file mode 100644 index 0000000..93d5db4 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-withheld-closed-with-ruling-and-a-refusal.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-withheld-open-counted.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-withheld-open-counted.msgpack new file mode 100644 index 0000000..2d91b27 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/cultlib-d3025d3c/entry-withheld-open-counted.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/error.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/error.response.frame.bin new file mode 100644 index 0000000..e3f3cb2 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/error.response.frame.bin differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/error.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/error.response.frame.hex new file mode 100644 index 0000000..fffbf7c --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/error.response.frame.hex @@ -0,0 +1 @@ +0000007385ad736368656d6156657273696f6eb063756c746e65742e6572726f722e7630a56572726f72d92f687567696e6e2e6465736b20726566757365732061206d6573736167655f6964206f76657220323536206279746573ab726f7574696e6748696e74c0a4636f6465c0a764657461696c73c0 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.frame.bin new file mode 100644 index 0000000..3937de0 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.frame.bin differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.frame.hex new file mode 100644 index 0000000..dd4c2ba --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.frame.hex @@ -0,0 +1 @@ +000001708aab646961676e6f737469637391b57061796c6f61642d6e6f742d612d72657175657374a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d95c6771526a6232526c745842686557787659575174626d39304c574574636d56786457567a644b64745a584e7a5957646c76325a705a57786b49455a76636d747a4c6d787062576c304f6942766458517462325974636d46755a32553daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61d92567616d6563756c742e63756c746e65742e6f7065726174696f6e5f6661696c7572652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a872656a6563746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.payload.hex new file mode 100644 index 0000000..815c014 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.payload.hex @@ -0,0 +1 @@ +82a4636f6465b57061796c6f61642d6e6f742d612d72657175657374a76d657373616765bf6669656c6420466f726b732e6c696d69743a206f75742d6f662d72616e6765 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.payload.msgpack new file mode 100644 index 0000000..e985a65 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/failure.response.payload.msgpack @@ -0,0 +1 @@ +‚¤codeµpayload-not-a-request§message¿field Forks.limit: out-of-range \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.frame.bin new file mode 100644 index 0000000..87fae5a Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.frame.bin differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.frame.hex new file mode 100644 index 0000000..f11745c --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.frame.hex @@ -0,0 +1 @@ +000001a287a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706441413daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.payload.hex new file mode 100644 index 0000000..804b47e --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697400 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.payload.msgpack new file mode 100644 index 0000000..d79df5f Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.request.payload.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.frame.bin new file mode 100644 index 0000000..586b679 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.frame.bin differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.frame.hex new file mode 100644 index 0000000..26d91d8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.frame.hex @@ -0,0 +1 @@ +0000019289a96d6573736167654964a36d2d33a96f7065726174696f6ea5666f726b73a77061796c6f6164d9b06761564762334a7263344b6b62476c7a6449657463324e6f5a573168566d567963326c766272566e5957316c593356736443356d62334a724c6d787063335175646a436b62576c755a4b5a6c64584a6c6132476c633352686258436d637a45774d546b77716d39775a57356659323931626e5144726e4a6c593239795a47566b58324e76645735304261566d62334a7263354372626d56346446396a64584a7a62334c417057467a5832396d7a53664faf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b7687567696e6e2e6465736b5f726573706f6e73652e7631ad736368656d6156657273696f6ebd63756c746e65742e6f7065726174696f6e5f726573706f6e73652e7630a9736572766963654964ab687567696e6e2e6465736baf736f7572636552756e74696d654964ad687567696e6e2d657572656b61a6737461747573a86163636570746564 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.payload.hex new file mode 100644 index 0000000..47af395 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.payload.hex @@ -0,0 +1 @@ +81a5466f726b7382a46c69737487ad736368656d6156657273696f6eb567616d6563756c742e666f726b2e6c6973742e7630a46d696e64a6657572656b61a57374616d70a6733130313930aa6f70656e5f636f756e7403ae7265636f726465645f636f756e7405a5666f726b7390ab6e6578745f637572736f72c0a561735f6f66cd27ce diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.payload.msgpack new file mode 100644 index 0000000..a040d33 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-counts.response.payload.msgpack @@ -0,0 +1 @@ +�¥Forks‚¤list‡­schemaVersionµgamecult.fork.list.v0¤mind¦eureka¥stamp¦s10190ªopen_count®recorded_count¥forks�«next_cursorÀ¥as_ofÍ'Î \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.frame.bin new file mode 100644 index 0000000..5184d42 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.frame.bin differ 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 0000000..d6dd07b Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-empty.response.payload.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.frame.bin new file mode 100644 index 0000000..6c5288e Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-non-decider.request.frame.bin differ 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 0000000..f2479d5 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-cursor.request.frame.bin differ 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 0000000..b720fa2 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.frame.bin differ 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 0000000..17b0abb Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open-non-decider.response.payload.msgpack differ 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 0000000..8ea0611 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.frame.bin differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.frame.hex new file mode 100644 index 0000000..c351ca8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.frame.hex @@ -0,0 +1 @@ +000001a287a96d6573736167654964a36d2d31a96f7065726174696f6ea5666f726b73a77061796c6f6164d9f06761564762334a726334616b62576c755a4b5a6c64584a6c6132476e63335669616d566a644e6b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d7147526c59326c6b5a584a7a6b746b6b4e575977597a4a694d5755744f4745305a433030597a4e694c546c6c4e3259744d574579596a4e6a4e4751315a545a6d32535177596a6c6b4e32557a59533032597a49784c54526d4e5467745954426b4e4330355a54686d4e324532596a566a4e47536d633352686448567a704739775a57366d5933567963323979774b5673615731706444493daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.payload.hex new file mode 100644 index 0000000..2006332 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.payload.hex @@ -0,0 +1 @@ +81a5466f726b7386a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a6737461747573a46f70656ea6637572736f72c0a56c696d697432 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.request.payload.msgpack new file mode 100644 index 0000000..0b18a68 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.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À¥limit2 \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.frame.bin new file mode 100644 index 0000000..23229fe Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.frame.bin differ 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 0000000..6dd129a Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-open.response.payload.msgpack differ 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 0000000..fec4275 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.frame.bin differ 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 0000000..26ac65e Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-response-too-large.response.payload.msgpack differ 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 0000000..e3ca7d5 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/list-too-large.response.frame.bin differ 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 0000000..90e6f35 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.request.frame.bin differ 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 0000000..3508983 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.frame.bin differ 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 0000000..96dfdb2 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-non-decider.response.payload.msgpack differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.frame.bin new file mode 100644 index 0000000..cdb47aa Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-too-large.response.frame.bin differ 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 0000000..4018637 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read-unknown.response.frame.bin differ 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 0000000..181317a Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.frame.bin differ diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.frame.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.frame.hex new file mode 100644 index 0000000..f581179 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.frame.hex @@ -0,0 +1 @@ +000001ca87a96d6573736167654964a36d2d34a96f7065726174696f6ea4666f726ba77061796c6f6164da01186761524762334a72684b52746157356b706d5631636d56725961647a64574a715a574e30325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d616f5a47566a6157526c636e4f53325351315a6a426a4d6d49785a5330345954526b4c54526a4d3249744f5755335a69307859544a694d324d305a44566c4e6d625a4a4442694f5751335a544e684c545a6a4d6a45744e4759314f4331684d4751304c546c6c4f47593359545a694e574d305a4b646d62334a7258326c6b3254426c64584a6c6132457463335669633352795958526c4f6e46315a584e30615739754f6d526c6332737462476c7a64476c755a7931686458526f62334a7064486b3daf7061796c6f6164456e636f64696e67b26d6573736167657061636b2d626173653634ad7061796c6f6164536368656d61b6687567696e6e2e6465736b5f726571756573742e7631ad736368656d6156657273696f6ebc63756c746e65742e6f7065726174696f6e5f726571756573742e7630a9736572766963654964ab687567696e6e2e6465736b diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.payload.hex b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.payload.hex new file mode 100644 index 0000000..efe17b0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.payload.hex @@ -0,0 +1 @@ +81a4466f726b84a46d696e64a6657572656b61a77375626a656374d92435663063326231652d386134642d346333622d396537662d316132623363346435653666a8646563696465727392d92435663063326231652d386134642d346333622d396537662d316132623363346435653666d92430623964376533612d366332312d346635382d613064342d396538663761366235633464a7666f726b5f6964d930657572656b612d7375627374726174653a7175657374696f6e3a6465736b2d6c697374696e672d617574686f72697479 diff --git a/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.payload.msgpack b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.payload.msgpack new file mode 100644 index 0000000..27941b5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.request.payload.msgpack @@ -0,0 +1 @@ +�¤Fork„¤mind¦eureka§subjectÙ$5f0c2b1e-8a4d-4c3b-9e7f-1a2b3c4d5e6f¨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.response.frame.bin b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.frame.bin new file mode 100644 index 0000000..a73b19e Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.frame.bin differ 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 0000000..a90ade3 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/read.response.payload.msgpack differ 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 0000000..6db3bbb Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-whole.payload.msgpack differ 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 0000000..d00019f Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/record-withheld.payload.msgpack differ 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 0000000..92b9314 Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/refused-unit.response.frame.bin differ 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 0000000..bbb82db Binary files /dev/null and b/tests/Bifrost.Web.Tests/Fixtures/desk-feed/unavailable.response.frame.bin differ 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/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/HeimdallAppSecretFileTests.cs b/tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs new file mode 100644 index 0000000..1094bb5 --- /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_trailing_newlines_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.WarningLines, 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.WarningLines, 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()); + } +} diff --git a/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletePageScriptTests.cs index da7ad9c..c79be6d 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,50 @@ 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]); + } + + [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/HeimdallCompletionTests.cs b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs index afcfcba..c619349 100644 --- a/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs +++ b/tests/Bifrost.Web.Tests/HeimdallCompletionTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Bifrost.Web.Domain; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Patronage; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Authentication.OAuth; @@ -888,6 +889,101 @@ public sealed class HeimdallCompletionTests Assert.Equal(rolesBefore, after.RoleAssignments.Count); } + [Fact] + public async Task A_merged_account_stays_merged_through_a_sign_in_with_the_member_claim() + { + using var host = new HeimdallHost(); + var foldedId = HeimdallShapes.NewAccountId(); + var survivorId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"])).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(member: true), survivorId, ["member_access"])).SignedIn); + await host.Db(async db => + { + var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == foldedId); + var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorId); + var merge = new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = AccountMergeState.HeimdallMoved, CreatedUtc = DateTimeOffset.UtcNow }; + db.AccountMerges.Add(merge); + await db.SaveChangesAsync(); + var clock = TimeProvider.System; + await new AccountMergeLedger(db, new PatronageService(db, new AuditTrailService(db, clock), clock), clock).CommitAsync(merge.Id, default); + return 0; + }); + Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status); + + var again = await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]); + + Assert.True(again.SignedIn); // the login still completes; it is the actor gate that makes it act as nobody + Assert.Equal(MembershipStatus.Merged, (await host.Account(foldedId))!.Membership!.Status); + } + + [Fact] + public async Task A_legacy_account_without_a_membership_row_signs_in_and_gets_one() + { + using var host = new HeimdallHost(); + var accountId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: false), accountId)).SignedIn); + await host.Db(async db => + { + db.Memberships.RemoveRange(db.Memberships.Where(x => x.UserAccount.HeimdallAccountId == accountId)); + await db.SaveChangesAsync(); + return 0; + }); + Assert.Null((await host.Account(accountId))!.Membership); + + var again = await Genuine(host, await host.StartAsync(member: true), accountId, ["member_access"]); + + Assert.True(again.SignedIn); + Assert.Equal(MembershipStatus.Active, (await host.Account(accountId))!.Membership!.Status); + } + + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.Committed)] + public async Task A_chat_tier_account_without_a_membership_that_a_merge_froze_or_folded_signs_in_without_getting_one(AccountMergeState state) + { + using var host = new HeimdallHost(); + var foldedId = HeimdallShapes.NewAccountId(); + var survivorId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: false), foldedId)).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(member: false), survivorId)).SignedIn); + await MergeAsync(host, survivorId, foldedId, state, dropFoldedMembership: true); // a chat-tier account holds none + + await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]); + + Assert.Null((await host.Account(foldedId))!.Membership); + Assert.Equal(0, await host.Db(db => db.Memberships.CountAsync(x => x.UserAccount.HeimdallAccountId == foldedId))); + } + + [Fact] + public async Task A_frozen_accounts_sign_in_with_member_access_does_not_activate_its_membership() + { + using var host = new HeimdallHost(); + var foldedId = HeimdallShapes.NewAccountId(); + var survivorId = HeimdallShapes.NewAccountId(); + Assert.True((await Genuine(host, await host.StartAsync(member: false), foldedId)).SignedIn); + Assert.True((await Genuine(host, await host.StartAsync(member: false), survivorId)).SignedIn); + await MergeAsync(host, survivorId, foldedId, AccountMergeState.HeimdallMoved, dropFoldedMembership: false); + + await Genuine(host, await host.StartAsync(member: true), foldedId, ["member_access"]); + + Assert.Equal(MembershipStatus.Authenticated, (await host.Account(foldedId))!.Membership!.Status); + } + + private static Task MergeAsync(HeimdallHost host, string survivorId, string foldedId, AccountMergeState state, bool dropFoldedMembership) => + host.Db(async db => + { + if (dropFoldedMembership) + { + db.Memberships.RemoveRange(db.Memberships.Where(x => x.UserAccount.HeimdallAccountId == foldedId)); + } + + var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == foldedId); + var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorId); + db.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = state, CreatedUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + return 0; + }); + [Fact] public async Task A_chat_only_account_that_later_signs_in_with_member_access_becomes_a_member() { @@ -1115,7 +1211,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 +1228,71 @@ 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 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_with_an_unusual_charset_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() { @@ -1445,7 +1606,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(); diff --git a/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs new file mode 100644 index 0000000..f773eb5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallGoogleLinkTests.cs @@ -0,0 +1,630 @@ +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.EntityFrameworkCore; +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 LinkedPage = "/Membership/LinkedAccounts"; + 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(LinkedPage + "?result=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(LinkedPage + "?result=error", Location(b.Response)); + AssertAttemptCleared(b.Response, start); + } + + [Fact] + public async Task A_link_begun_before_a_merge_froze_the_account_does_not_continue_after_it() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var survivorAccount = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + await host.SignInSessionAsync(survivorAccount); + var start = await host.StartLinkAsync(session); + await host.Db(async db => + { + var folded = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == account); + var survivor = await db.UserAccounts.SingleAsync(x => x.HeimdallAccountId == survivorAccount); + db.AccountMerges.Add(new AccountMerge { SurvivorUserAccountId = survivor.Id, FoldedUserAccountId = folded.Id, State = AccountMergeState.Confirmed, CreatedUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + return 0; + }); + var starts = host.Heimdall.StartBodies.Count; + + var b = await StepB(host, session, start, account); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); // no Google link was asked for on the frozen account + Assert.Equal(LinkedPage + "?result=error", Location(b.Response)); + } + + [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(LinkedPage + "?result=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(LinkedPage + "?result=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(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()); + 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($"{LinkedPage}?result={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, LinkedPage + "?result=" + Uri.EscapeDataString(link)); + request.Headers.Add("Cookie", $"{BifrostCookies.Session}={session}"); + return await (await client.SendAsync(request)).Content.ReadAsStringAsync(); + } + + 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("now linked to your", 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(LinkedPage + "?result=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(LinkedPage + "?result=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(LinkedPage + "?result=linked", Location(first)); + + var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + + Assert.Single(host.Heimdall.ConfirmBodies); + Assert.Equal(LinkedPage + "?result=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(LinkedPage + "?result=linked", Location(d.Response)); + Assert.Equal(LinkedPage + "?result=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(LinkedPage + "?result=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("11")] + [InlineData("-1")] + [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); + 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(LinkedPage + "?result=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(LinkedPage + "?result=error", Location(d.Response)); + Assert.Single(host.Heimdall.ConfirmBodies); + AssertAttemptCleared(d.Response, journey.C.Next); + } +} diff --git a/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs new file mode 100644 index 0000000..1e4cc01 --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallIdentityMoveClientTests.cs @@ -0,0 +1,728 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Tests.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moved = Bifrost.Web.Features.Heimdall.HeimdallMoveOutcome.Moved; +using Refused = Bifrost.Web.Features.Heimdall.HeimdallMoveOutcome.Refused; +using Unknown = Bifrost.Web.Features.Heimdall.HeimdallMoveOutcome.Unknown; + +namespace Bifrost.Web.Tests; + +/// +/// Bifrost's client for Heimdall's identity-move route, from the caller's side, against the fake Heimdall's account model, which answers +/// as Heimdall 4fe367a's service contract does. Moved and Refused are definitive; anything else is Unknown and is healed by replaying the same moveId. +/// +public sealed class HeimdallIdentityMoveClientTests +{ + private const string Passkey = "bifrost-passkey"; + private const string EmailCode = "bifrost-email-code"; + + private static string NewMoveId() => "move-" + Guid.NewGuid().ToString("N"); + + private static string NewAccount() => HeimdallShapes.NewAccountId(); + + private static HeimdallIdentityMoveClient ClientOf(HeimdallHost host) => host.Services.GetRequiredService(); + + private static Task Move(HeimdallHost host, HeimdallMoveRequest request) => + ClientOf(host).MoveAsync(request, CancellationToken.None); + + private static HeimdallMoveReceipt Receipt(HeimdallMoveOutcome outcome) => Assert.IsType(outcome).Receipt; + + /// An account holding these providers, one login each, in order. + private static (string Account, string[] Ids) AccountWith(FakeMoves moves, params string[] providers) + { + var account = NewAccount(); + return (account, providers.Select(p => moves.AddLogin(account, p)).ToArray()); + } + + /// The usual forward move of everything: a passkey proof of the source and a weak proof of the destination. + private static HeimdallMoveRequest Forward(FakeMoves moves, string source, string destination, string? destinationLogin = null, string[]? ids = null) => + new(NewMoveId(), [moves.Issue(source, stepUpMethod: Passkey)], moves.Issue(destination, destinationLogin), ids); + + // ---- the forward move and its receipt ---- + + [Fact] + public async Task A_forward_move_with_one_weak_proof_per_login_answers_the_receipt_under_the_app_secret_and_names_no_account() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, destinationIds) = AccountWith(moves, "twitch"); + var request = new HeimdallMoveRequest( + NewMoveId(), [moves.Issue(source, ids[0]), moves.Issue(source, ids[1])], moves.Issue(destination, destinationIds[0])); + + var receipt = Receipt(await Move(host, request)); + + Assert.Equal(new HeimdallMoveReceipt(request.MoveId, destination, ids, HeimdallProofTier.Weak, HeimdallProofTier.Weak, null), receipt with { MovedIdentityIds = ids }); + Assert.Equal(ids, receipt.MovedIdentityIds); + Assert.Equal(["twitch", "discord", "patreon"], moves.Logins[destination].Select(x => x.Provider)); + Assert.Empty(moves.Logins[source]); + var sent = Assert.Single(moves.Requests); + Assert.Equal(HeimdallHost.AppSecret, sent.Secret); + Assert.Null(sent.Authorization); + Assert.DoesNotContain(source, sent.Body); + Assert.DoesNotContain(destination, sent.Body); + var names = JsonDocument.Parse(sent.Body).RootElement.EnumerateObject().Select(x => x.Name).Order().ToArray(); + Assert.Equal(["fromProofs", "moveId", "toProof"], names); + } + + [Fact] + public async Task A_proof_through_one_login_moves_only_that_login_and_only_an_asserted_passkey_speaks_for_the_account() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (destination, _) = AccountWith(moves, "twitch"); + + // Discord proves Discord; Patreon, and so the whole account, is not proved by it. + var (a, aIds) = AccountWith(moves, "discord", "patreon"); + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0])], moves.Issue(destination), aIds[1..]))); + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0])], moves.Issue(destination)))); + Assert.Equal(2, moves.Logins[a].Count); + + // An email code proves an inbox: strong, fresh, and still only its own login. + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0], EmailCode)], moves.Issue(destination)))); + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotProved), + await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0], EmailCode)], moves.Issue(destination), aIds[1..]))); + Assert.Equal(2, moves.Logins[a].Count); + + // The same proof does move the one login it came through. + Assert.Equal([aIds[0]], Receipt(await Move(host, new(NewMoveId(), [moves.Issue(a, aIds[0], EmailCode)], moves.Issue(destination), aIds[..1]))).MovedIdentityIds); + (destination, _) = AccountWith(moves, "twitch"); + + // An asserted passkey moves everything. + var (b, bIds) = AccountWith(moves, "discord", "patreon", "google"); + var all = Receipt(await Move(host, Forward(moves, b, destination))); + Assert.Equal(bIds, all.MovedIdentityIds); + Assert.Equal(HeimdallProofTier.Strong, all.ProofFrom); + } + + [Fact] + public async Task A_proof_by_a_stale_weak_sign_in_or_step_up_is_refused_with_its_side_at_the_window_edge() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (destination, _) = AccountWith(moves, "twitch"); + + HeimdallMoveRequest Weak(string source, string login, int ago, int toAgo = 0) => + new(NewMoveId(), [moves.Issue(source, login, authAgo: ago)], moves.Issue(destination, authAgo: toAgo)); + + var (a, a1) = AccountWith(moves, "discord"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.From), await Move(host, Weak(a, a1[0], 301))); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.To), await Move(host, Weak(a, a1[0], 0, 301))); + Assert.Equal(HeimdallProofTier.Weak, Receipt(await Move(host, Weak(a, a1[0], 300))).ProofFrom); + + var (b, _) = AccountWith(moves, "patreon"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), [moves.Issue(b, stepUpMethod: Passkey, authAgo: 601, stepUpAgo: 601)], moves.Issue(destination)))); + Assert.Equal(HeimdallProofTier.Strong, + Receipt(await Move(host, new(NewMoveId(), [moves.Issue(b, stepUpMethod: Passkey, authAgo: 600, stepUpAgo: 600)], moves.Issue(destination)))).ProofFrom); + } + + // ---- replay ---- + + [Fact] + public async Task A_replayed_move_id_answers_the_first_receipt_after_the_sources_sessions_were_revoked_and_the_tokens_expired() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + var first = Receipt(await Move(host, request)); + Assert.True(moves.IsRevoked(request.FromProofs[0])); + host.Clock.Advance(TimeSpan.FromHours(2)); + + var again = Receipt(await Move(host, request)); + + Assert.Equal(first.AccountId, again.AccountId); + Assert.Equal(ids, again.MovedIdentityIds); + Assert.Equal(first.ProofFrom, again.ProofFrom); + Assert.Single(moves.Stored); + Assert.Equal(2, moves.Requests.Count); + } + + [Fact] + public async Task A_replay_with_different_identity_ids_answers_the_first_receipt_and_moves_nothing_more() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, _) = AccountWith(moves, "twitch"); + var proofs = new[] { moves.Issue(source, stepUpMethod: Passkey) }; + var toProof = moves.Issue(destination); + var moveId = NewMoveId(); + + var first = Receipt(await Move(host, new(moveId, proofs, toProof, ids[..1]))); + var again = Receipt(await Move(host, new(moveId, proofs, toProof, ids))); + + Assert.Equal([ids[0]], first.MovedIdentityIds); + Assert.Equal([ids[0]], again.MovedIdentityIds); + Assert.Equal([ids[1]], moves.Logins[source].Select(x => x.Id)); + } + + [Fact] + public async Task A_move_whose_answer_was_lost_heals_by_replaying_the_same_request() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + + moves.MoveApplyThenFail = HttpStatusCode.InternalServerError; + Assert.IsType(await Move(host, request)); + Assert.Single(moves.Stored); + moves.MoveApplyThenFail = HttpStatusCode.OK; + Assert.IsType(await Move(host, request)); + + moves.MoveApplyThenFail = null; + var healed = Receipt(await Move(host, request)); + Assert.Equal(destination, healed.AccountId); + Assert.Equal(ids, healed.MovedIdentityIds); + Assert.Single(moves.Stored); + } + + // ---- reversal ---- + + [Fact] + public async Task A_reversal_moves_back_exactly_the_logins_the_forward_move_carried_once() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, destinationIds) = AccountWith(moves, "twitch"); + var forward = Forward(moves, source, destination, ids: ids[..1]); + Receipt(await Move(host, forward)); + Assert.Equal(["twitch", "discord"], moves.Logins[destination].Select(x => x.Provider)); + + // The destination's bearer proves a login it holds; the carried one is proved by its own sign-in. + var undo = new HeimdallMoveRequest(NewMoveId(), [moves.Issue(destination, ids[0])], ReversesMoveId: forward.MoveId); + var receipt = Receipt(await Move(host, undo)); + + Assert.Equal(source, receipt.AccountId); + Assert.Equal([ids[0]], receipt.MovedIdentityIds); + Assert.Equal(forward.MoveId, receipt.ReversesMoveId); + Assert.Null(receipt.ProofTo); + Assert.Equal([destinationIds[0]], moves.Logins[destination].Select(x => x.Id)); + Assert.Equal(ids.Order(), moves.Logins[source].Select(x => x.Id).Order()); + + Assert.Equal(new Refused(HeimdallMoveRefusal.NotReversible), + await Move(host, new(NewMoveId(), [moves.Issue(destination, destinationIds[0], Passkey)], ReversesMoveId: forward.MoveId))); + Assert.Equal(new Refused(HeimdallMoveRefusal.NotReversible), + await Move(host, new(NewMoveId(), [moves.Issue(source, ids[0], Passkey)], ReversesMoveId: undo.MoveId))); + } + + // ---- refusals ---- + + [Fact] + public async Task Each_contract_refusal_code_maps_to_its_member_and_only_provider_conflict_carries_a_provider() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + + // not_allowed_for_app + var (s0, _) = AccountWith(moves, "discord"); + var (d0, _) = AccountWith(moves, "twitch"); + moves.Granted = false; + Assert.Equal(new Refused(HeimdallMoveRefusal.NotAllowedForApp), await Move(host, Forward(moves, s0, d0))); + moves.Granted = true; + + // proof_invalid: unknown, revoked, foreign audience (from side); a lapsed destination proof (to side) + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), ["no-such-bearer"], moves.Issue(d0)))); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey, aud: "someone-else")], moves.Issue(d0)))); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.To), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey)], "no-such-bearer"))); + var (other, _) = AccountWith(moves, "google"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid, HeimdallProofSide.From), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey), moves.Issue(other, stepUpMethod: Passkey)], moves.Issue(d0)))); + + // same_account + Assert.Equal(new Refused(HeimdallMoveRefusal.SameAccount), + await Move(host, new(NewMoveId(), [moves.Issue(s0, stepUpMethod: Passkey)], moves.Issue(s0)))); + + // identity_not_found: the proof is a passkey, the login is nobody's + Assert.Equal(new Refused(HeimdallMoveRefusal.IdentityNotFound), + await Move(host, Forward(moves, s0, d0, ids: [Guid.NewGuid().ToString()]))); + + // provider_conflict: the destination already holds a Discord login + var (s1, _) = AccountWith(moves, "discord"); + var (d1, _) = AccountWith(moves, "discord"); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), await Move(host, Forward(moves, s1, d1))); + + // move_id_conflict is not a refusal: a committed move answers it when its proofs no longer verify, so the caller keeps its proofs. + var (s2, _) = AccountWith(moves, "patreon"); + var (d2, _) = AccountWith(moves, "twitch"); + var used = Forward(moves, s2, d2); + Receipt(await Move(host, used)); + var (s3, _) = AccountWith(moves, "patreon"); + Assert.Equal(new Unknown("move_id_conflict"), + await Move(host, new(used.MoveId, [moves.Issue(s3, stepUpMethod: Passkey)], moves.Issue(d2)))); + Assert.Equal(new Unknown("move_id_conflict"), + await Move(host, new(used.MoveId, ["forged-bearer"], used.ToProof))); + + // already_moved: s2 now holds nothing and an unreversed move left it + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), await Move(host, Forward(moves, s2, d0))); + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), await Move(host, Forward(moves, s3, s2))); + + // not_reversible: a move that never happened + Assert.Equal(new Refused(HeimdallMoveRefusal.NotReversible), + await Move(host, new(NewMoveId(), [moves.Issue(d2, stepUpMethod: Passkey)], ReversesMoveId: NewMoveId()))); + } + + [Fact] + public async Task A_wrong_app_secret_is_refused_as_app_auth_required() + { + using var host = new HeimdallHost(appSecret: "not-the-secret"); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + + Assert.Equal(new Refused(HeimdallMoveRefusal.AppAuthRequired), await Move(host, Forward(moves, source, destination))); + Assert.Empty(moves.Stored); + } + + [Fact] + public async Task A_400_carrying_Heimdalls_invalid_request_code_is_RequestInvalid_a_definitive_refusal_that_moves_nothing() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + moves.MoveStatus = HttpStatusCode.BadRequest; + + Assert.Equal(new Refused(HeimdallMoveRefusal.RequestInvalid), await Move(host, Forward(moves, source, destination))); + Assert.Empty(moves.Stored); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound, "404 Not Found")] + [InlineData(HttpStatusCode.NotFound, "")] + [InlineData(HttpStatusCode.BadRequest, "400 Bad Requestnginx")] + [InlineData(HttpStatusCode.BadRequest, "")] + [InlineData(HttpStatusCode.BadRequest, """{"statusCode":400,"error":"Bad Request","message":"x"}""")] + [InlineData(HttpStatusCode.BadRequest, """{"message":"no error field"}""")] + [InlineData(HttpStatusCode.Conflict, """{"error":"invalid_request"}""")] + [InlineData(HttpStatusCode.RequestEntityTooLarge, """{"error":"invalid_request"}""")] // Heimdall's own 413 for a body over its limit; the client's rule is exactly 400 + [InlineData(HttpStatusCode.UnsupportedMediaType, """{"error":"invalid_request"}""")] + [InlineData(HttpStatusCode.RequestTimeout, "")] + [InlineData(HttpStatusCode.RequestTimeout, """{"error":"request_timeout"}""")] + [InlineData(HttpStatusCode.Gone, "")] + [InlineData(HttpStatusCode.UnavailableForLegalReasons, "")] + [InlineData(HttpStatusCode.Unauthorized, """{"error":"weird_code"}""")] + [InlineData(HttpStatusCode.Forbidden, """{"message":"no error field"}""")] + [InlineData((HttpStatusCode)418, """{"error":"app_auth_required_sort_of"}""")] + public async Task A_4xx_without_one_of_Heimdalls_move_codes_is_Unknown_because_it_proves_nothing_was_applied_no_more_than_a_gateway_does( + HttpStatusCode status, string body) + { + Assert.IsType(await Answered(body, Plain, status)); + } + + [Fact] + public async Task A_404_with_no_body_from_the_route_is_Unknown_not_identity_not_found() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + moves.MoveStatus = HttpStatusCode.NotFound; + + Assert.IsType(await Move(host, Forward(moves, source, destination))); + } + + [Fact] + public async Task An_exception_of_any_kind_from_the_pipeline_is_Unknown_and_only_the_callers_cancellation_propagates() + { + Exception[] thrown = + [ + new InvalidOperationException("handler"), new ObjectDisposedException("connection"), + new System.Security.Authentication.AuthenticationException("tls"), new InvalidDataException("zip"), new UriFormatException("uri"), + new HttpRequestException("down"), new IOException("reset"), new TaskCanceledException("deadline") + ]; + foreach (var error in thrown) + { + var client = new HeimdallIdentityMoveClient( + new HttpClient(new Throwing(error)) { BaseAddress = new Uri("https://heimdall.test") }, + Options.Create(new HeimdallOptions()), NullLogger.Instance); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + } + } + + private sealed class Throwing(Exception error) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => throw error; + } + + // ---- unknown outcomes ---- + + [Theory] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.Found)] + public async Task A_5xx_a_429_or_any_other_status_is_Unknown_never_a_refusal(HttpStatusCode status) + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + moves.MoveStatus = status; + + Assert.IsType(await Move(host, Forward(moves, source, destination))); + } + + [Fact] + public async Task A_timeout_a_transport_error_and_garbage_with_a_200_are_Unknown_while_the_callers_own_cancellation_propagates() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var (source, _) = AccountWith(moves, "discord"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + + moves.MoveDown = new TaskCanceledException("the client timed out"); + Assert.IsType(await Move(host, request)); + moves.MoveDown = new HttpRequestException("Heimdall is down"); + Assert.IsType(await Move(host, request)); + moves.MoveDown = null; + moves.MoveApplyThenFail = HttpStatusCode.OK; + Assert.IsType(await Move(host, request)); + moves.MoveApplyThenFail = null; + + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + await Assert.ThrowsAnyAsync(() => ClientOf(host).MoveAsync(request, cancelled.Token)); + } + + private sealed class Stub(Func content, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public Stub(string json, HttpStatusCode status = HttpStatusCode.OK) + : this(() => new StringContent(json, Encoding.UTF8, "application/json"), status) + { + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(status) { Content = content() }); + } + + /// A body that serves a prefix and then stalls, resets, or streams endless filler. + private sealed class Body(string prefix, bool reset = false, long endless = 0) : Stream + { + private readonly byte[] _prefix = Encoding.UTF8.GetBytes(prefix); + + public long Served { get; private set; } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position { get => Served; set => throw new NotSupportedException(); } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => ReadAsync(buffer.AsMemory(offset, count)).AsTask().GetAwaiter().GetResult(); + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (Served < _prefix.Length) + { + var n = (int)Math.Min(buffer.Length, _prefix.Length - Served); + _prefix.AsMemory((int)Served, n).CopyTo(buffer); + Served += n; + return n; + } + + if (endless > 0 && Served < _prefix.Length + endless) + { + var n = (int)Math.Min(buffer.Length, _prefix.Length + endless - Served); + buffer.Span[..n].Fill((byte)'a'); + Served += n; + return n; + } + + if (reset) + { + throw new IOException("connection reset mid-body"); + } + + await Task.Delay(Timeout.Infinite, cancellationToken); + return 0; + } + } + + private static HeimdallIdentityMoveClient Client(HttpMessageHandler handler, TimeSpan? timeout = null) => + new(new HttpClient(handler) { BaseAddress = new Uri("https://heimdall.test"), Timeout = timeout ?? TimeSpan.FromSeconds(30) }, + Options.Create(new HeimdallOptions()), NullLogger.Instance); + + private static Task Answered(string json, HeimdallMoveRequest request, HttpStatusCode status = HttpStatusCode.OK) => + Client(new Stub(json, status)).MoveAsync(request, CancellationToken.None); + + private static readonly HeimdallMoveRequest Plain = new("move-0123456789abcdef", ["p"], "q"); + + [Fact] + public async Task A_200_that_is_not_a_whole_receipt_for_this_request_is_Unknown() + { + var id = Guid.NewGuid().ToString(); + var forward = new HeimdallMoveRequest("move-0123456789abcdef", ["p"], "q"); + var reversal = new HeimdallMoveRequest("move-0123456789abcdef", ["p"], ReversesMoveId: "move-fedcba9876543210"); + string Body(string moveId = "move-0123456789abcdef", string? reverses = null, string proof = """{"from":"weak","to":"weak"}""", string ids = "\"" + "ID" + "\"") => + $$"""{"moveId":"{{moveId}}","accountId":"acct","movedIdentityIds":[{{ids.Replace("ID", id)}}],"proof":{{proof}}{{(reverses is null ? "" : $",\"reversesMoveId\":\"{reverses}\"")}}}"""; + + // The well formed shapes are accepted, so each refusal below is about its one flaw. + Assert.IsType(await Answered(Body(), forward)); + Assert.IsType(await Answered(Body(ids: "\"not-a-guid-but-an-id\""), forward)); + Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-fedcba9876543210", """{"from":"strong","to":null}"""), reversal)); + + Assert.IsType(await Answered(Body("move-someoneelse12345"), forward)); + Assert.IsType(await Answered(Body(reverses: "move-fedcba9876543210"), forward)); + Assert.IsType(await Answered(Body(), reversal)); + Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-other0123456789", """{"from":"strong","to":null}"""), reversal)); + Assert.IsType(await Answered(Body("move-0123456789abcdef", "move-fedcba9876543210", """{"from":"strong","to":"weak"}"""), reversal)); + Assert.IsType(await Answered(Body(proof: """{"from":"strong","to":null}"""), forward)); + Assert.IsType(await Answered(Body(proof: """{"from":"maybe","to":"weak"}"""), forward)); + Assert.IsType(await Answered(Body(ids: ""), forward)); + Assert.IsType(await Answered(Body(ids: "123"), forward)); + Assert.IsType(await Answered("""{"moveId":"move-0123456789abcdef"}""", forward)); + Assert.IsType(await Answered("[]", forward)); + } + + // ---- the closed body ---- + + [Fact] + public async Task A_request_that_breaks_the_contracts_body_throws_before_any_request_reaches_Heimdall() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var good = NewMoveId(); + var proofs = new[] { "p" }; + var broken = new HeimdallMoveRequest[] + { + new(good, proofs, "t", ReversesMoveId: NewMoveId()), + new(good, proofs), + new(good, Enumerable.Repeat("p", 9).ToArray(), "t"), + new(good, [], "t"), + new("short", proofs, "t"), + new("bad move id with spaces", proofs, "t"), + new(good, proofs, ReversesMoveId: "short"), + new(good, proofs, IdentityIds: ["i"], ReversesMoveId: NewMoveId()), + new(good, proofs, "t", []), + new(good, proofs, "t", Enumerable.Repeat("i", 33).ToArray()), + new(good, proofs, "t", ["i", "j", "i"]), + new(good, [new string('p', HeimdallIdentityMoveClient.MaxProofLength + 1)], "t"), + new(good, proofs, new string('p', HeimdallIdentityMoveClient.MaxProofLength + 1)), + new(good, [""], "t"), + new(good + "\n", proofs, "t"), + new(good, proofs, ReversesMoveId: good + "\n"), + new("move-0123456789abcdef\n", proofs, "t"), + new(new string('a', 65), proofs, "t"), + new(new string('a', 15), proofs, "t"), + new(good, proofs, ReversesMoveId: new string('a', 65)) + }; + + foreach (var request in broken) + { + await Assert.ThrowsAsync(() => Move(host, request)); + } + + Assert.Empty(moves.Requests); + } + + // ---- the wire: bounds, deadline, redirects, secrets ---- + + [Fact] + public async Task A_body_that_stalls_after_the_headers_is_Unknown_within_the_clients_deadline_for_a_receipt_and_a_refusal() + { + foreach (var (status, prefix) in new[] { (HttpStatusCode.OK, """{"moveId":"move-0123456789abcdef","""), (HttpStatusCode.Conflict, """{"error":"pro""") }) + { + var client = Client(new Stub(() => new StreamContent(new Body(prefix)), status), TimeSpan.FromMilliseconds(500)); + var task = client.MoveAsync(Plain, CancellationToken.None); + Assert.Same(task, await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(10)))); + Assert.IsType(await task); + } + } + + [Fact] + public async Task A_connection_reset_while_reading_the_body_is_Unknown_not_an_exception() + { + var client = Client(new Stub(() => new StreamContent(new Body("""{"moveId":"move-0123456789abcdef",""", reset: true)))); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + } + + [Fact] + public async Task A_receipt_body_past_64_KiB_is_refused_unread_as_Unknown() + { + var body = new Body("""{"moveId":"move-0123456789abcdef","accountId":" """, endless: 64L * 1024 * 1024); + var client = Client(new Stub(() => new StreamContent(body))); + + Assert.IsType(await client.MoveAsync(Plain, CancellationToken.None)); + Assert.True(body.Served < 1024 * 1024, $"read {body.Served} bytes"); + } + + [Fact] + public async Task Refusal_qualifiers_are_kept_only_for_the_codes_that_carry_them() + { + Assert.Equal(new Refused(HeimdallMoveRefusal.SameAccount), + await Answered("""{"error":"same_account","proof":"from","provider":"discord"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.AlreadyMoved), + await Answered("""{"error":"already_moved","proof":"to"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProviderConflict, Provider: "discord"), + await Answered("""{"error":"provider_conflict","proof":"to","provider":"discord"}""", Plain, HttpStatusCode.Conflict)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofStale, HeimdallProofSide.To), + await Answered("""{"error":"proof_stale","proof":"to","provider":"discord"}""", Plain, HttpStatusCode.Forbidden)); + Assert.Equal(new Refused(HeimdallMoveRefusal.ProofInvalid), + await Answered("""{"error":"proof_invalid","proof":"sideways"}""", Plain, HttpStatusCode.Forbidden)); + } + + [Fact] + public async Task The_largest_legal_request_is_sent_and_moves_what_it_names() + { + using var host = new HeimdallHost(); + var moves = host.Heimdall.Moves; + var source = NewAccount(); + var ids = Enumerable.Range(0, 32).Select(_ => moves.AddLogin(source, "discord")).ToArray(); + var (destination, _) = AccountWith(moves, "twitch"); + var proofs = Enumerable.Range(0, 8).Select(_ => moves.Issue(source, stepUpMethod: Passkey)).ToArray(); + + var longest = new string('m', 64); + var receipt = Receipt(await Move(host, new(longest, proofs, moves.Issue(destination), ids))); + Assert.Equal(longest, receipt.MoveId); + + Assert.Equal(ids, receipt.MovedIdentityIds); + Assert.Equal(8, JsonDocument.Parse(moves.Requests[0].Body).RootElement.GetProperty("fromProofs").GetArrayLength()); + } + + [Fact] + public void A_request_does_not_print_its_proofs() + { + var request = new HeimdallMoveRequest("move-0123456789abcdef", ["FROM-BEARER-SECRET"], "TO-BEARER-SECRET"); + + Assert.DoesNotContain("BEARER-SECRET", request.ToString()); + Assert.Contains("move-0123456789abcdef", request.ToString()); + } + + private static ServiceProvider RealRegistration(string baseUrl) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = baseUrl, AppSecret = "APP-SECRET-VALUE" })); + services.AddHeimdallIdentityMoveClient(); + return services.BuildServiceProvider(); + } + + [Fact] + public void The_registered_client_carries_the_app_secret_and_the_15_second_deadline() + { + using var provider = RealRegistration("https://heimdall.test"); + var http = provider.GetRequiredService().CreateClient(nameof(HeimdallIdentityMoveClient)); + + Assert.Equal(TimeSpan.FromSeconds(15), http.Timeout); + Assert.Equal(new Uri("https://heimdall.test"), http.BaseAddress); + Assert.Equal("APP-SECRET-VALUE", Assert.Single(http.DefaultRequestHeaders.GetValues(HeimdallAuthService.AppSecretHeader))); + } + + [Theory] + [InlineData(307)] + [InlineData(302)] + public async Task A_redirect_is_Unknown_and_nothing_reaches_the_other_origin(int code) + { + static int FreePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + int first = FreePort(), second = FreePort(); + using var origin = new HttpListener(); + origin.Prefixes.Add($"http://127.0.0.1:{first}/"); + origin.Start(); + using var other = new HttpListener(); + other.Prefixes.Add($"http://127.0.0.1:{second}/"); + other.Start(); + var redirected = Task.Run(async () => + { + var context = await origin.GetContextAsync(); + context.Response.StatusCode = code; + context.Response.RedirectLocation = $"http://127.0.0.1:{second}/steal"; + context.Response.Close(); + }); + var stolen = new List(); + _ = Task.Run(async () => + { + try + { + var context = await other.GetContextAsync(); + stolen.Add(context.Request.Headers["x-heimdall-app-secret"] + new StreamReader(context.Request.InputStream).ReadToEnd()); + var forged = Encoding.UTF8.GetBytes($$$"""{"moveId":"move-0123456789abcdef","accountId":"attacker","movedIdentityIds":["{{{Guid.NewGuid()}}}"],"proof":{"from":"weak","to":"weak"}}"""); + context.Response.StatusCode = 200; + await context.Response.OutputStream.WriteAsync(forged); + context.Response.Close(); + } + catch (HttpListenerException) + { + } + catch (ObjectDisposedException) + { + } + }); + using var provider = RealRegistration($"http://127.0.0.1:{first}"); + + var outcome = await provider.GetRequiredService() + .MoveAsync(new("move-0123456789abcdef", ["FROM-BEARER"], "TO-BEARER"), CancellationToken.None); + await redirected; + await Task.Delay(300); + + Assert.IsType(outcome); + Assert.Empty(stolen); + } + + // ---- what the logs hold ---- + + [Fact] + public async Task With_http_trace_logging_on_no_log_line_holds_a_bearer_the_secret_an_identity_id_or_an_account_id() + { + using var host = new HeimdallHost(traceHttp: true); + var moves = host.Heimdall.Moves; + var (source, ids) = AccountWith(moves, "discord", "patreon"); + var (destination, _) = AccountWith(moves, "twitch"); + var request = Forward(moves, source, destination); + Receipt(await Move(host, request)); + Assert.IsType(await Move(host, Forward(moves, source, destination))); + + var logs = string.Join('\n', host.Logs.Lines); + Assert.Contains("Heimdall identity move: Moved", logs); + Assert.Contains("Heimdall identity move: Refused", logs); + Assert.Contains("identity-moves", logs); + // Only the secret and bearer headers are redacted: an ordinary header's value is logged, so the shared redaction is what is in force. + Assert.Contains("application/json", logs); + foreach (var secret in new[] { HeimdallHost.AppSecret, source, destination, request.FromProofs[0], request.ToProof!, request.MoveId }.Concat(ids)) + { + Assert.DoesNotContain(secret, logs); + } + } +} diff --git a/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs b/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs new file mode 100644 index 0000000..cce754b --- /dev/null +++ b/tests/Bifrost.Web.Tests/HeimdallRedirectTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +/// +/// Every Heimdall client as Program registers it (): a 3xx from Heimdall's origin +/// is the answer, never a second request. The app secret and a member's bearer reach only the origin they were configured for. +/// +public sealed class HeimdallRedirectTests +{ + private static int FreePort() + { + var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + [Theory] + [InlineData(typeof(HeimdallAuthService), 307)] + [InlineData(typeof(HeimdallAuthService), 302)] + [InlineData(typeof(HeimdallMemberClient), 307)] + [InlineData(typeof(HeimdallMemberClient), 302)] + [InlineData(typeof(HeimdallIdentityMoveClient), 307)] + public async Task A_redirect_from_Heimdall_is_not_followed_and_no_secret_or_bearer_reaches_the_other_host(Type client, int code) + { + int first = FreePort(), second = FreePort(); + using var origin = new HttpListener(); + origin.Prefixes.Add($"http://127.0.0.1:{first}/"); + origin.Start(); + using var other = new HttpListener(); + other.Prefixes.Add($"http://127.0.0.1:{second}/"); + other.Start(); + var redirected = Task.Run(async () => + { + var context = await origin.GetContextAsync(); + context.Response.StatusCode = code; + context.Response.RedirectLocation = $"http://127.0.0.1:{second}/steal"; + context.Response.Close(); + }); + var stolen = new List(); + _ = Task.Run(async () => + { + try + { + var context = await other.GetContextAsync(); + stolen.Add(context.Request.Headers["x-heimdall-app-secret"] + context.Request.Headers["Authorization"]); + context.Response.StatusCode = 200; + context.Response.Close(); + } + catch (Exception error) when (error is HttpListenerException or ObjectDisposedException) + { + } + }); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Options.Create(new HeimdallOptions { BaseUrl = $"http://127.0.0.1:{first}", AppSecret = "APP-SECRET-VALUE" })); + services.AddHeimdallClients(); + using var provider = services.BuildServiceProvider(); + using var http = provider.GetRequiredService().CreateClient(client.Name); + using var request = new HttpRequestMessage(HttpMethod.Get, "/v1/me/identities"); + request.Headers.Authorization = new("Bearer", "MEMBER-BEARER"); + + using var response = await http.SendAsync(request); + await redirected; + await Task.Delay(300); + + Assert.Equal(code, (int)response.StatusCode); + Assert.Empty(stolen); + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 717062d..8ad0a0a 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_other_refusals_never_echo_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 { diff --git a/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs new file mode 100644 index 0000000..bd2348a --- /dev/null +++ b/tests/Bifrost.Web.Tests/LinkedAccountsTests.cs @@ -0,0 +1,912 @@ +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, bool? stepUpRequested = null) + { + 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)); + } + + 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, 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); + } + + // ---- 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 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); + } + + [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(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")] + [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] + public async Task A_session_with_no_Heimdall_account_has_nothing_to_list_and_starts_nothing() + { + using var host = new HeimdallHost(); + + 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); + 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 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 logins through", 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 logins through", (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 logins through", (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); + } + + [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")] + [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); + // 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] + 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.DoesNotContain("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("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); + } + + [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)); + } + + [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")] + [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("provider_needed")] + [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/MembersPageNoticeTests.cs b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs new file mode 100644 index 0000000..ec87c6d --- /dev/null +++ b/tests/Bifrost.Web.Tests/MembersPageNoticeTests.cs @@ -0,0 +1,333 @@ +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); + } + + 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() + { + 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/MergeAccountsPageTests.cs b/tests/Bifrost.Web.Tests/MergeAccountsPageTests.cs new file mode 100644 index 0000000..8210b8b --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeAccountsPageTests.cs @@ -0,0 +1,574 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static Bifrost.Web.Tests.AccountMergeSagaTests; + +namespace Bifrost.Web.Tests; + +/// +/// The merge page from the member's side: browser sessions of the real host over the saga rig and the fake Heimdall. Stages derive +/// from the proofs held; the other account is unnamed until both proofs exist; consent is two boxes and the saga's Confirmed row. +/// +public sealed class MergeAccountsPageTests +{ + private const string Url = MergePage.AccountsUrl; + private const string OtherName = "banks-2d7c"; + + private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text); + + private sealed class World : IDisposable + { + public Rig Rig { get; private init; } = null!; + + public Party Me { get; private init; } = null!; + + public Party Other { get; private init; } = null!; + + public string Session { get; private init; } = string.Empty; + + /// The name the account carries after signing in (a sign-in refreshes it from Heimdall). + public string MyName { get; private init; } = string.Empty; + + public MergePage.Antiforgery? Token { get; private set; } + + public HeimdallHost Host => Rig.Host; + + public List Surface { get; } = []; + + public List Bearers { get; } = []; + + public MergeProofs Proofs => Host.Services.GetRequiredService(); + + public static async Task Make(bool enabled = true, string[]? otherProviders = null) + { + var rig = new Rig(realSessions: true, accountMerge: enabled); + var me = await rig.AccountAsync("quill-9f3a", "discord"); + var other = await rig.AccountAsync(OtherName, otherProviders ?? ["patreon", "google"]); + var session = await rig.Host.SignInSessionAsync(me.HeimdallId, "discord"); + var myName = await rig.InScopeAsync(async sp => (await sp.GetRequiredService().UserAccounts.AsNoTracking().SingleAsync(x => x.Id == me.Id)).DisplayName); + var world = new World { Rig = rig, Me = me, Other = other, Session = session, MyName = myName }; + if (enabled) + { + world.Token = await MergePage.TokenAsync(rig.Host, session, Url); + } + + return world; + } + + public void Dispose() => Rig.Dispose(); + + public async Task<(HttpResponseMessage Response, string Html)> Get(string query = "", string? session = null) + { + var (response, html) = await MergePage.GetAsync(Host, session ?? Session, query, Url); + Surface.Add(html + "\n" + MergePage.Location(response)); + return (response, html); + } + + public async Task Page(string query = "") + { + var (response, html) = await Get(query); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return html; + } + + public async Task Post(string handler, params (string, string)[] form) + { + var response = await MergePage.PostAsync(Host, Session, Token!, handler, form.ToDictionary(x => x.Item1, x => x.Item2), Url); + Surface.Add(MergePage.Location(response) + "\n" + string.Join('\n', response.Headers.Select(x => x.Key + string.Concat(x.Value)))); + return response; + } + + /// One fresh sign-in of through its login of , started by the page's own button and finished as Heimdall's return does. + public async Task Prove(string handler, Party proved, string provider) + { + var before = Host.Heimdall.StartBodies.Count; + var started = await Post(handler, ("via", provider), ("keep", handler == "ProveRemaining" ? "self" : string.Empty)); + Assert.Equal(HttpStatusCode.Redirect, started.StatusCode); + var attempt = Host.AttemptFrom(started, before); + Assert.NotEmpty(attempt.Nonce); + + Host.Heimdall.Identities.Clear(); + foreach (var (name, id) in proved.Logins) + { + Host.Heimdall.AddIdentity(name, id: id); + } + + var bearer = Host.Token(proved.HeimdallId); + Rig.Moves.Issue(proved.HeimdallId, proved.Logins[provider], bearer: bearer); + Bearers.Add(bearer); + var done = await Host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, proved.HeimdallId, bearer, provider: provider) }, + $"{BifrostCookies.Session}={Session}; {attempt.CookieName}={attempt.Cookie}"); + Surface.Add(MergePage.Location(done)); + Assert.Equal(Url + "?result=merge_proof_recorded", MergePage.Location(done)); + } + + public Task ProveSelf(string provider = "discord") => Prove("ProveSelf", Me, provider); + + public Task ProveOther(string provider) => Prove("ProveOther", Other, provider); + + /// Both accounts proved in full, so either can be kept. + public async Task ProveAll() + { + await ProveSelf(); + await ProveOther("patreon"); + await ProveOther("google"); + } + + public static string DigestIn(string html) => Regex.Match(html, "name=\"digest\"[^>]*value=\"([0-9a-f]{64})\"").Groups[1].Value; + + public Task Confirm(string keep, string digest, bool self = true, bool other = true) + { + var form = new List<(string, string)> { ("keep", keep), ("digest", digest) }; + if (self) + { + form.Add(("consentSelf", "true")); + } + + if (other) + { + form.Add(("consentOther", "true")); + } + + return Post("Confirm", [.. form]); + } + } + + private static string Where(string keep, string code) => $"{Url}?keep={keep}&merge={code}"; + + // ---- the flag and the gates ---- + + [Fact] + public async Task Disabled_the_page_is_404_and_Linked_accounts_renders_no_link() + { + using var w = await World.Make(enabled: false); + + var (response, _) = await w.Get(); + var (_, linked) = await w.Host.GetLinkedAccountsAsync(w.Session); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.DoesNotContain("Merge two accounts", linked); + Assert.DoesNotContain("MergeAccounts", linked); + } + + [Fact] + public async Task Enabled_Linked_accounts_links_to_the_page() + { + using var w = await World.Make(); + + var (_, linked) = await w.Host.GetLinkedAccountsAsync(w.Session); + + Assert.Contains("Merge two accounts", linked); + Assert.Contains(Url, linked); + } + + [Fact] + public async Task A_frozen_account_is_forbidden_the_page() + { + using var w = await World.Make(); + await w.Rig.ConfirmedAsync(w.Me, w.Other, w.Rig.Weak(w.Me, "discord"), w.Rig.Weak(w.Other, "patreon"), w.Rig.Weak(w.Other, "google")); + var folded = await w.Host.SignInSessionAsync(w.Other.HeimdallId, "patreon"); + + var (response, _) = await w.Get(session: folded); + + AssertRefused(response); + } + + /// Forbid answers 403 or sends the browser to the access-denied page; either way the page is not served. + private static void AssertRefused(HttpResponseMessage response) => + Assert.True(response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Found, response.StatusCode.ToString()); + + // ---- stages derive from the proofs held ---- + + [Fact] + public async Task With_no_proof_the_page_asks_for_both_and_says_nothing_is_named_until_both_exist() + { + using var w = await World.Make(); + + var page = await w.Page(); + + Assert.Contains("ProveSelf", page); + Assert.Contains("ProveOther", page); + Assert.Contains("nothing is named until both", page); + Assert.DoesNotContain("Keep this account", page); + } + + [Fact] + public async Task Nothing_about_the_other_account_appears_before_its_proof_is_stored() + { + using var w = await World.Make(); + await w.ProveSelf(); + + var onlySelf = await w.Page(); + var onlySelfKept = await w.Page("?keep=other"); + + Assert.DoesNotContain(OtherName, onlySelf + onlySelfKept); + Assert.DoesNotContain(w.Other.HeimdallId, onlySelf + onlySelfKept); + Assert.DoesNotContain(w.Other.Id.ToString(), onlySelf + onlySelfKept); + Assert.DoesNotContain("Keep this account", onlySelf); + } + + [Fact] + public async Task The_other_proof_alone_names_neither_account() + { + using var w = await World.Make(); + await w.ProveOther("patreon"); + + var page = await w.Page(); + + Assert.DoesNotContain(OtherName, page); + Assert.DoesNotContain("Keep this account", page); + } + + [Fact] + public async Task Both_proofs_name_both_accounts_by_display_name_and_offer_each_survivor() + { + using var w = await World.Make(); + await w.ProveSelf(); + await w.ProveOther("patreon"); + + var page = await w.Page(); + + Assert.Contains(w.MyName, page); + Assert.Contains(OtherName, page); + Assert.Contains("Keep this account", page); + Assert.Contains("Keep the other account", page); + Assert.Contains("keep=self", page); + Assert.Contains("keep=other", page); + Assert.DoesNotContain(w.Other.HeimdallId, page); + Assert.DoesNotContain(w.Other.Id.ToString(), page); + } + + [Fact] + public async Task The_folded_sides_unproved_logins_are_asked_one_sign_in_each_and_the_kept_side_needs_none() + { + using var w = await World.Make(); + await w.ProveSelf(); + await w.ProveOther("patreon"); + + var keepSelf = await w.Page("?keep=self"); + var keepOther = await w.Page("?keep=other"); + + Assert.Contains("ProveRemaining", keepSelf); + Assert.Contains("Google", keepSelf); + Assert.DoesNotContain("name=\"digest\"", keepSelf); + Assert.DoesNotContain("ProveRemaining", keepOther); + Assert.Matches("name=\"digest\"", keepOther); + + await w.Prove("ProveRemaining", w.Other, "google"); + Assert.Matches("name=\"digest\"", await w.Page("?keep=self")); + } + + [Fact] + public async Task Proofs_older_than_their_lifetime_fall_out_and_the_page_asks_again() + { + using var w = await World.Make(); + await w.ProveAll(); + Assert.Matches("name=\"digest\"", await w.Page("?keep=other")); + + w.Rig.Clock.Advance(MergeProofs.ProofLifetime + TimeSpan.FromSeconds(1)); + var page = await w.Page("?keep=other"); + + Assert.Contains("ProveSelf", page); + Assert.DoesNotContain("name=\"digest\"", page); + Assert.DoesNotContain(OtherName, page); + } + + // ---- the preview and the digest ---- + + [Fact] + public async Task The_two_directions_preview_with_different_digests_and_a_stale_one_is_PreviewStale_and_keeps_the_proofs() + { + using var w = await World.Make(); + await w.ProveAll(); + var keepSelf = World.DigestIn(await w.Page("?keep=self")); + var keepOther = World.DigestIn(await w.Page("?keep=other")); + Assert.NotEqual(keepSelf, keepOther); + Assert.Equal(await w.Rig.DigestAsync(w.Me, w.Other), keepSelf); + Assert.Equal(await w.Rig.DigestAsync(w.Other, w.Me), keepOther); + + var response = await w.Confirm("self", keepOther); + + Assert.Equal(Where("self", "preview_stale"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Self); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other); + Assert.Contains(Enc(MergeWording.Confirm("preview_stale")), await w.Page("?keep=self&merge=preview_stale")); + } + + [Fact] + public async Task A_change_after_the_member_looked_makes_the_digest_stale() + { + using var w = await World.Make(); + await w.ProveAll(); + var seen = World.DigestIn(await w.Page("?keep=self")); + var motion = await w.Rig.MotionAsync(w.Me); + await w.Rig.VoteAsync(motion, w.Other, VoteChoice.For); + + var response = await w.Confirm("self", seen); + + Assert.Equal(Where("self", "preview_stale"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + } + + [Fact] + public async Task The_preview_shows_the_plans_counts_and_the_static_statements_and_never_the_digests_inputs() + { + using var w = await World.Make(); + var motion = await w.Rig.MotionAsync(w.Me); + await w.Rig.VoteAsync(motion, w.Other, VoteChoice.For); + await w.ProveAll(); + + var page = await w.Page("?keep=self"); + + Assert.Contains("1 open vote ", page); + foreach (var statement in MergePreview.Statements) + { + Assert.Contains(Enc(statement), page); + } + } + + [Fact] + public async Task A_blocked_pair_shows_the_blocks_words_and_no_confirm_form() + { + using var w = await World.Make(); + var third = await w.Rig.AccountAsync("third-77aa", "discord"); + await w.Rig.ConfirmedAsync(third, w.Other, w.Rig.Weak(third, "discord"), w.Rig.Weak(w.Other, "patreon"), w.Rig.Weak(w.Other, "google")); + await w.ProveAll(); + + var page = await w.Page("?keep=other"); + + Assert.Contains(Enc(MergePreview.Block("merge_in_flight")), page); + Assert.DoesNotContain("consentSelf", page); + } + + [Theory] + [InlineData("self")] + [InlineData("other")] + public async Task Two_accounts_holding_a_login_of_one_provider_are_blocked_before_consent_and_the_block_names_only_the_provider(string keep) + { + using var w = await World.Make(otherProviders: ["discord", "patreon"]); + await w.ProveSelf(); + await w.ProveOther("discord"); + await w.ProveOther("patreon"); + + var page = await w.Page($"?keep={keep}"); + + Assert.Contains("Both accounts hold a Discord login", page); + Assert.DoesNotContain("consentSelf", page); + Assert.DoesNotContain("name=\"digest\"", page); + Assert.DoesNotContain("ProveRemaining", page); + var words = Regex.Match(page, "Both accounts hold a[^<]*").Value; + Assert.DoesNotContain(OtherName, words); + Assert.DoesNotContain(w.MyName, words); + Assert.DoesNotContain(w.Other.HeimdallId, words); + } + + [Fact] + public async Task A_confirm_for_accounts_holding_one_providers_login_is_Blocked_writes_nothing_and_keeps_the_proofs() + { + using var w = await World.Make(otherProviders: ["discord", "patreon"]); + await w.ProveSelf(); + await w.ProveOther("discord"); + await w.ProveOther("patreon"); + var digest = await w.Rig.DigestAsync(w.Me, w.Other); + + var response = await w.Confirm("self", digest); + + Assert.Equal(Where("self", "blocked"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Self); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other); + Assert.Empty(w.Rig.Moves.Requests); + } + + // ---- confirm ---- + + [Fact] + public async Task Confirm_with_only_the_self_proof_does_not_reach_the_saga() + { + using var w = await World.Make(); + await w.ProveSelf(); + var digest = await w.Rig.DigestAsync(w.Me, w.Other); + + var response = await w.Confirm("self", digest); + + Assert.Equal(Where("self", "proofs_needed"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + Assert.Empty(w.Rig.Moves.Requests); + } + + [Fact] + public async Task Confirm_with_the_folded_sides_logins_not_all_proved_does_not_reach_the_saga() + { + using var w = await World.Make(); + await w.ProveSelf(); + await w.ProveOther("patreon"); + var digest = await w.Rig.DigestAsync(w.Me, w.Other); + + var response = await w.Confirm("self", digest); + + Assert.Equal(Where("self", "proofs_needed"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task Confirm_without_both_consent_boxes_writes_nothing_and_keeps_the_proofs(bool self, bool other) + { + using var w = await World.Make(); + await w.ProveAll(); + var digest = World.DigestIn(await w.Page("?keep=other")); + + var response = await w.Confirm("other", digest, self, other); + + Assert.Equal(Where("other", "consent_needed"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other); + } + + [Fact] + public async Task A_confirm_that_is_not_self_or_other_is_BadChoice() + { + using var w = await World.Make(); + await w.ProveAll(); + + var response = await w.Confirm("both", "x"); + + Assert.Equal(Url + "?merge=bad_choice", MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + } + + [Fact] + public async Task A_blocked_confirm_is_Blocked_and_keeps_the_proofs() + { + using var w = await World.Make(); + await w.ProveAll(); + var digest = World.DigestIn(await w.Page("?keep=other")); + var third = await w.Rig.AccountAsync("third-77aa", "discord"); + await w.Rig.ConfirmedAsync(third, w.Other, w.Rig.Weak(third, "discord"), w.Rig.Weak(w.Other, "patreon"), w.Rig.Weak(w.Other, "google")); + var merges = await w.Rig.MergeCountAsync(); + + var response = await w.Confirm("other", digest); + + Assert.Equal(Where("other", "blocked"), MergePage.Location(response)); + Assert.Equal(merges, await w.Rig.MergeCountAsync()); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Self); + } + + [Fact] + public async Task A_proof_of_a_Heimdall_account_the_member_account_no_longer_has_is_ProofAccountMismatch_and_keeps_the_proofs() + { + using var w = await World.Make(); + await w.ProveAll(); + var digest = World.DigestIn(await w.Page("?keep=other")); + await w.Rig.InScopeAsync(async sp => + { + var db = sp.GetRequiredService(); + (await db.UserAccounts.SingleAsync(x => x.Id == w.Me.Id)).HeimdallAccountId = HeimdallShapes.NewAccountId(); + return await db.SaveChangesAsync(); + }); + + var response = await w.Confirm("other", digest); + + Assert.Equal(Where("other", "proof_account_mismatch"), MergePage.Location(response)); + Assert.Equal(0, await w.Rig.MergeCountAsync()); + Assert.NotNull(w.Proofs.Snapshot(w.Me.Id).Other); + } + + [Theory] + [InlineData("self")] + [InlineData("other")] + public async Task A_confirm_in_either_direction_leaves_one_row_with_the_typed_consent_columns_sealed_proofs_and_spent_proofs(string keep) + { + using var w = await World.Make(); + await w.ProveAll(); + var digest = World.DigestIn(await w.Page("?keep=" + keep)); + + var response = await w.Confirm(keep, digest); + + Assert.Equal(MergePage.Url, MergePage.Location(response)); + var survivor = keep == "self" ? w.Me : w.Other; + var merge = Assert.Single(await w.Rig.InScopeAsync(sp => sp.GetRequiredService().AccountMerges.AsNoTracking().ToListAsync())); + Assert.Equal(AccountMergeState.Committed, merge.State); + Assert.Equal(survivor.Id, merge.SurvivorUserAccountId); + Assert.Equal(w.Me.Id, merge.ConfirmedByUserAccountId); + Assert.Equal(digest, merge.PreviewDigest); + Assert.Null(merge.SealedProofs); // spent by the move that landed + if (keep == "self") + { + Assert.Equal("discord", merge.SurvivorProofProvider); + Assert.Equal("google,patreon", merge.FoldedProofProviders); + } + else + { + Assert.Contains(merge.SurvivorProofProvider, new[] { "patreon", "google" }); + Assert.Equal("discord", merge.FoldedProofProviders); + } + + Assert.Equal(new MergeProofsView(null, null), w.Proofs.Snapshot(w.Me.Id)); + } + + [Fact] + public async Task A_confirm_while_Heimdall_is_unreachable_still_lands_on_Merge_status_with_the_row_Confirmed_and_the_proofs_sealed() + { + using var w = await World.Make(); + await w.ProveAll(); + var digest = World.DigestIn(await w.Page("?keep=self")); + w.Rig.Moves.MoveDown = new HttpRequestException("down"); + + var response = await w.Confirm("self", digest); + + Assert.Equal(MergePage.Url, MergePage.Location(response)); + var merge = Assert.Single(await w.Rig.InScopeAsync(sp => sp.GetRequiredService().AccountMerges.AsNoTracking().ToListAsync())); + Assert.Equal(AccountMergeState.Confirmed, merge.State); + Assert.NotNull(merge.SealedProofs); + Assert.Equal(new MergeProofsView(null, null), w.Proofs.Snapshot(w.Me.Id)); + } + + [Fact] + public async Task The_whole_flow_by_sign_ins_ends_on_the_survivors_status_with_a_notice_and_the_folded_cookie_has_no_actor() + { + using var w = await World.Make(); + var foldedSession = await w.Host.SignInSessionAsync(w.Other.HeimdallId, "patreon"); + await w.ProveSelf(); + await w.ProveOther("patreon"); + await w.Prove("ProveRemaining", w.Other, "google"); + var digest = World.DigestIn(await w.Page("?keep=self")); + + var response = await w.Confirm("self", digest); + + Assert.Equal(MergePage.Url, MergePage.Location(response)); + var (_, status) = await MergePage.GetAsync(w.Host, w.Session); + Assert.Contains(Enc(MergeWording.State(AccountMergeState.Committed)), status); + Assert.Contains("Undo window ends", status); + Assert.Contains("can still be undone", status); + var (foldedResponse, _) = await w.Host.GetLinkedAccountsAsync(foldedSession); + AssertRefused(foldedResponse); + + var everything = string.Join('\n', w.Surface) + status; + Assert.All(w.Bearers, bearer => Assert.DoesNotContain(bearer, everything)); + Assert.DoesNotContain(w.Other.HeimdallId, everything); + } + + [Fact] + public async Task Proving_without_naming_a_provider_starts_nothing_and_comes_back_with_the_error_code() + { + using var w = await World.Make(); + var started = w.Host.Heimdall.StartBodies.Count; + + var response = await w.Post("ProveOther"); + + Assert.Equal(Url + "?result=error", MergePage.Location(response)); + Assert.Equal(started, w.Host.Heimdall.StartBodies.Count); + } +} diff --git a/tests/Bifrost.Web.Tests/MergeNoticeAuthTests.cs b/tests/Bifrost.Web.Tests/MergeNoticeAuthTests.cs new file mode 100644 index 0000000..71f9e85 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeNoticeAuthTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Text; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static Bifrost.Web.Tests.AccountMergeSagaTests; + +namespace Bifrost.Web.Tests; + +/// Who may pull merge notices: the chat host's own bearer and nobody else, whatever else the request carries (the test host signs every request in as an admin). +public sealed class MergeNoticeAuthTests +{ + private const string Token = "chat-notices-test-token-0123456789"; + private const string Get = "/api/merge-notices/chat"; + private const string Ack = "/api/merge-notices/chat/ack"; + + private static HttpRequestMessage Request(HttpMethod method, string url, string? authorization, string? body = null) + { + var request = new HttpRequestMessage(method, url); + if (authorization is not null) + { + request.Headers.TryAddWithoutValidation("Authorization", authorization); + } + + if (body is not null) + { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + return request; + } + + private static string AckBody(Guid id) => $"{{\"merge_id\":\"{id}\",\"type\":\"merged\"}}"; + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task With_no_token_configured_both_routes_are_404_even_for_a_bearer_that_looks_right(string? token) + { + using var rig = new Rig(chatNoticesToken: token); + using var client = rig.Host.NewClient(); + + foreach (var authorization in new[] { null, "Bearer " + Token, "Bearer " }) + { + using var read = await client.SendAsync(Request(HttpMethod.Get, Get, authorization)); + using var ack = await client.SendAsync(Request(HttpMethod.Post, Ack, authorization, AckBody(Guid.NewGuid()))); + Assert.Equal(HttpStatusCode.NotFound, read.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, ack.StatusCode); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Bearer")] + [InlineData("Bearer ")] + [InlineData("Bearer wrong-wrong-wrong-wrong-wrong-wrong")] + [InlineData("Bearer chat-notices-test-token-012345678")] // one short + [InlineData("Bearer chat-notices-test-token-01234567890")] // one long + [InlineData("Bearer c")] + [InlineData("chat-notices-test-token-0123456789")] + [InlineData("Basic chat-notices-test-token-0123456789")] + [InlineData("Bearer chat-notices-test-token-0123456789, Bearer other")] + public async Task A_missing_wrong_short_or_long_token_is_401_with_no_body_on_both_routes_and_acks_nothing(string? authorization) + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, _, _) = await Committed(rig); + using var client = rig.Host.NewClient(); + + using var read = await client.SendAsync(Request(HttpMethod.Get, Get, authorization)); + using var ack = await client.SendAsync(Request(HttpMethod.Post, Ack, authorization, AckBody(merge))); + + foreach (var response in new[] { read, ack }) + { + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Empty(await response.Content.ReadAsStringAsync()); + Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); + } + + Assert.Equal(0, await rig.InScopeAsync(async sp => await sp.GetRequiredService().AccountMergeNoticeAcks.CountAsync())); + } + + [Fact] + public async Task A_token_in_the_query_or_a_cookie_is_not_a_bearer() + { + using var rig = new Rig(chatNoticesToken: Token); + using var client = rig.Host.NewClient(); + + using var query = await client.SendAsync(Request(HttpMethod.Get, $"{Get}?token={Token}&access_token={Token}", null)); + using var cookie = Request(HttpMethod.Get, Get, null); + cookie.Headers.Add("Cookie", $"token={Token}"); + using var withCookie = await client.SendAsync(cookie); + + Assert.Equal(HttpStatusCode.Unauthorized, query.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, withCookie.StatusCode); + } + + [Fact] + public async Task The_right_token_reads_and_acks_with_no_store_and_no_cors_or_cookie() + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, _, _) = await Committed(rig); + using var client = rig.Host.NewClient(); + + using var read = await client.SendAsync(Request(HttpMethod.Get, Get, "Bearer " + Token)); + using var ack = await client.SendAsync(Request(HttpMethod.Post, Ack, "Bearer " + Token, AckBody(merge))); + + Assert.Equal(HttpStatusCode.OK, read.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, ack.StatusCode); + foreach (var response in new[] { read, ack }) + { + Assert.Contains("no-store", response.Headers.CacheControl?.ToString()); + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(response.Headers.Contains("Set-Cookie")); + } + + Assert.Contains("application/json", read.Content.Headers.ContentType?.ToString()); + } + + internal static async Task<(Guid Merge, Party Survivor, Party Folded)> Committed(Rig rig) + { + var survivor = await rig.AccountAsync("survivor", "twitch"); + var folded = await rig.AccountAsync("folded", "discord"); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), rig.Weak(folded, "discord")); + Assert.Equal(Bifrost.Web.Domain.AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + return (id, survivor, folded); + } +} diff --git a/tests/Bifrost.Web.Tests/MergeNoticeFeedTests.cs b/tests/Bifrost.Web.Tests/MergeNoticeFeedTests.cs new file mode 100644 index 0000000..4577583 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeNoticeFeedTests.cs @@ -0,0 +1,341 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static Bifrost.Web.Tests.AccountMergeSagaTests; + +namespace Bifrost.Web.Tests; + +/// +/// The merge notice feed from the chat host's side: pulled over HTTP with the bearer, acked over HTTP, against merges the saga rig made; +/// bounds and the unique ack key from the feed itself over seeded rows. Notices are derived from the merge row: no stored status. +/// +public sealed class MergeNoticeFeedTests(MergePostgres postgres) : IClassFixture +{ + private const string Token = "chat-notices-test-token-0123456789"; + + private static readonly string[] PayloadKeys = + ["type", "merge_id", "occurred_at", "grace_ends_at", "old_subject", "new_subject", "old_preferred_username", "new_preferred_username"]; + + private static async Task PullAsync(Rig rig, string query = "") + { + using var client = rig.Host.NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/merge-notices/chat" + query); + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.EnumerateArray().Select(x => x.Clone()).ToArray(); + } + + private static async Task AckAsync(Rig rig, Guid merge, string type) + { + using var client = rig.Host.NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/merge-notices/chat/ack") + { + Content = new StringContent($"{{\"merge_id\":\"{merge}\",\"type\":\"{type}\"}}", Encoding.UTF8, "application/json") + }; + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token); + using var response = await client.SendAsync(request); + return response.StatusCode; + } + + private static string[] Types(JsonElement[] notices) => [.. notices.Select(x => x.GetProperty("type").GetString()!)]; + + private static Task<(Guid Merge, Party Survivor, Party Folded)> CommittedAsync(Rig rig) => MergeNoticeAuthTests.Committed(rig); + + private static async Task FinalizeAsync(Rig rig) + { + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromMinutes(1)); + await rig.InScopeAsync(async sp => await sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default)); + } + + // ---- what a notice says ---- + + [Fact] + public async Task A_committed_merge_yields_one_merged_notice_with_exactly_the_documented_keys_and_nothing_that_names_a_person() + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, survivor, folded) = await CommittedAsync(rig); + var committed = await rig.MergeAsync(merge); + + var notice = Assert.Single(await PullAsync(rig)); + + Assert.Equal(PayloadKeys.Order(), notice.EnumerateObject().Select(x => x.Name).Order()); + Assert.Equal("merged", notice.GetProperty("type").GetString()); + Assert.Equal(merge.ToString("D"), notice.GetProperty("merge_id").GetString()); + Assert.Equal(committed.CommittedUtc, notice.GetProperty("occurred_at").GetDateTimeOffset()); + Assert.Equal(committed.GraceEndsUtc, notice.GetProperty("grace_ends_at").GetDateTimeOffset()); + Assert.Equal(folded.Id.ToString("D"), notice.GetProperty("old_subject").GetString()); + Assert.Equal(survivor.Id.ToString("D"), notice.GetProperty("new_subject").GetString()); + Assert.Equal("folded", notice.GetProperty("old_preferred_username").GetString()); + Assert.Equal("survivor", notice.GetProperty("new_preferred_username").GetString()); + Assert.DoesNotContain(folded.HeimdallId, notice.GetRawText()); + Assert.DoesNotContain(survivor.HeimdallId, notice.GetRawText()); + } + + [Fact] + public async Task An_undone_merge_yields_merged_then_undone_and_each_is_acked_alone() + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, survivor, folded) = await CommittedAsync(rig); + rig.Clock.Advance(TimeSpan.FromHours(1)); + Assert.IsType(await rig.InScopeAsync(sp => + sp.GetRequiredService().RequestUndoAsync(merge, survivor.Id, [rig.Moved(survivor, folded, "discord")], default))); + Assert.Equal(AccountMergeState.Undone, (await rig.AdvanceAsync(merge)).State); + var stored = await rig.MergeAsync(merge); + + var notices = await PullAsync(rig); + + Assert.Equal(["merged", "undone"], Types(notices)); + Assert.Equal(stored.UndoneUtc, notices[1].GetProperty("occurred_at").GetDateTimeOffset()); + Assert.Equal(stored.GraceEndsUtc, notices[1].GetProperty("grace_ends_at").GetDateTimeOffset()); + Assert.Equal(HttpStatusCode.NoContent, await AckAsync(rig, merge, "merged")); + Assert.Equal(["undone"], Types(await PullAsync(rig))); + } + + [Fact] + public async Task A_finalized_merge_yields_merged_then_final() + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, _, _) = await CommittedAsync(rig); + await FinalizeAsync(rig); + var stored = await rig.MergeAsync(merge); + Assert.Equal(AccountMergeState.Final, stored.State); + + var notices = await PullAsync(rig); + + Assert.Equal(["merged", "final"], Types(notices)); + Assert.Equal(stored.FinalizedUtc, notices[1].GetProperty("occurred_at").GetDateTimeOffset()); + } + + [Fact] + public async Task A_confirmed_a_frozen_and_a_failed_merge_yield_nothing() + { + using var rig = new Rig(chatNoticesToken: Token); + var survivor = await rig.AccountAsync("survivor", "twitch"); + var frozen = await rig.AccountAsync("frozen", "discord"); + var stale = await rig.AccountAsync("stale", "patreon"); + var confirmed = await rig.ConfirmedAsync(survivor, frozen, rig.Weak(survivor, "twitch"), rig.Weak(frozen, "discord")); + Assert.Equal(AccountMergeState.Confirmed, (await rig.MergeAsync(confirmed)).State); + Assert.IsType(await rig.StandingAsync(frozen)); + Assert.Empty(await PullAsync(rig)); + + var other = await rig.AccountAsync("other", "twitch2"); + var failing = await rig.ConfirmedAsync(other, stale, rig.Weak(other, "twitch2"), rig.Weak(stale, "patreon")); + rig.Clock.Advance(TimeSpan.FromSeconds(400)); + Assert.Equal(AccountMergeState.Failed, (await rig.AdvanceAsync(failing)).State); + + // The first merge is still Confirmed and the second Failed: neither committed, neither is told. + Assert.Empty(await PullAsync(rig)); + } + + // ---- ack ---- + + [Fact] + public async Task An_ack_is_idempotent_and_the_notice_is_gone_from_the_next_read() + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, _, _) = await CommittedAsync(rig); + Assert.Single(await PullAsync(rig)); + + Assert.Equal(HttpStatusCode.NoContent, await AckAsync(rig, merge, "merged")); + Assert.Equal(HttpStatusCode.NoContent, await AckAsync(rig, merge, "merged")); + + Assert.Empty(await PullAsync(rig)); + Assert.Equal(1, await rig.InScopeAsync(async sp => await sp.GetRequiredService().AccountMergeNoticeAcks.CountAsync())); + } + + [Fact] + public async Task An_ack_of_a_notice_the_feed_would_not_emit_is_refused_and_stores_nothing() + { + using var rig = new Rig(chatNoticesToken: Token); + var (merge, _, _) = await CommittedAsync(rig); // merged only: not undone, not final + + Assert.Equal(HttpStatusCode.NotFound, await AckAsync(rig, merge, "undone")); + Assert.Equal(HttpStatusCode.NotFound, await AckAsync(rig, merge, "final")); + Assert.Equal(HttpStatusCode.NotFound, await AckAsync(rig, Guid.NewGuid(), "merged")); + Assert.Equal(HttpStatusCode.BadRequest, await AckAsync(rig, merge, "created")); + Assert.Equal(HttpStatusCode.BadRequest, await AckAsync(rig, merge, "Merged")); + + Assert.Equal(0, await rig.InScopeAsync(async sp => await sp.GetRequiredService().AccountMergeNoticeAcks.CountAsync())); + Assert.Single(await PullAsync(rig)); + } + + [Fact] + public async Task A_malformed_ack_body_is_400_and_stores_nothing() + { + using var rig = new Rig(chatNoticesToken: Token); + await CommittedAsync(rig); + using var client = rig.Host.NewClient(); + foreach (var body in new[] { "", "not json", "{}", "{\"merge_id\":\"nope\",\"type\":\"merged\"}", "{\"type\":\"merged\"}", "[]" }) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/merge-notices/chat/ack") { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + Assert.Equal(0, await rig.InScopeAsync(async sp => await sp.GetRequiredService().AccountMergeNoticeAcks.CountAsync())); + } + + // ---- order, bounds, after: the feed over seeded rows ---- + + private static readonly DateTimeOffset T0 = AccountMergeLedgerTests.T0; + + private static async Task<(AccountMergeLedgerTests.World World, UserAccount Survivor, UserAccount Folded)> SeededAsync(Func> merges, string? postgres = null) + { + var world = postgres is null ? new AccountMergeLedgerTests.World() : AccountMergeLedgerTests.World.OnPostgres(postgres); + await using (var migrate = world.Db()) + { + if (postgres is not null) + { + await migrate.Database.MigrateAsync(); + } + } + + var survivor = await world.AccountAsync("survivor"); + var folded = await world.AccountAsync("folded"); + await using var db = world.Db(); + db.AccountMerges.AddRange(merges(survivor.Id, folded.Id)); + await db.SaveChangesAsync(); + return (world, survivor, folded); + } + + private static AccountMerge Committed(Guid survivor, Guid folded, DateTimeOffset at, Guid? id = null) => new() + { + Id = id ?? Guid.NewGuid(), State = AccountMergeState.Committed, SurvivorUserAccountId = survivor, FoldedUserAccountId = folded, + CreatedUtc = at, CommittedUtc = at, GraceEndsUtc = at + AccountMergeLedger.Grace + }; + + private static MergeNoticeFeed Feed(BifrostDbContext db) => new(db, TimeProvider.System); + + [Fact] + public async Task Notices_come_ordered_by_occurred_at_then_merge_id_then_kind_and_after_keeps_only_later_ones() + { + var low = new Guid("00000000-0000-0000-0000-000000000001"); + var high = new Guid("00000000-0000-0000-0000-000000000002"); + var (world, _, _) = await SeededAsync((s, f) => + [ + Committed(s, f, T0.AddHours(3), high), + Committed(s, f, T0.AddHours(3), low), + Committed(s, f, T0.AddHours(1), Guid.NewGuid()) + ]); + await using var db = world.Db(); + + var all = await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default); + var later = await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, T0.AddHours(1), null, default); + + Assert.Equal([T0.AddHours(1), T0.AddHours(3), T0.AddHours(3)], all.Select(x => x.OccurredAt)); + Assert.Equal([low, high], all.Skip(1).Select(x => x.MergeId)); + Assert.Equal([low, high], later.Select(x => x.MergeId)); + } + + [Fact] + public async Task A_merge_that_is_undone_and_one_that_is_final_order_their_two_notices_by_their_own_times() + { + var (world, _, _) = await SeededAsync((s, f) => + { + var undone = Committed(s, f, T0); + undone.State = AccountMergeState.Undone; + undone.UndoneUtc = T0.AddHours(5); + var final = Committed(s, f, T0.AddHours(1)); + final.State = AccountMergeState.Final; + final.FinalizedUtc = T0.AddHours(2); + return [undone, final]; + }); + await using var db = world.Db(); + + var kinds = (await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default)).Select(x => x.Kind); + + Assert.Equal([AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Merged, AccountMergeNoticeKind.Final, AccountMergeNoticeKind.Undone], kinds); + } + + [Theory] + [InlineData(null, 50)] + [InlineData(0, 50)] + [InlineData(-5, 50)] + [InlineData(3, 3)] + [InlineData(200, 200)] + [InlineData(100000, 200)] + public async Task The_limit_is_bounded_default_fifty_maximum_two_hundred(int? asked, int expected) + { + var (world, _, _) = await SeededAsync((s, f) => Enumerable.Range(0, 230).Select(i => Committed(s, f, T0.AddMinutes(i)))); + await using var db = world.Db(); + + var read = await Feed(db).ReadAsync(AccountMergeNoticeTarget.Chat, null, asked, default); + + Assert.Equal(expected, read.Count); + Assert.Equal(T0.AddMinutes(expected - 1), read[^1].OccurredAt); // the oldest first, cut at the limit + } + + [Fact] + public async Task The_feed_writes_no_merge_row_only_acks() + { + var (world, _, _) = await SeededAsync((s, f) => [Committed(s, f, T0)]); + await using var db = world.Db(); + var before = await db.AccountMerges.AsNoTracking().SingleAsync(); + var feed = Feed(db); + + await feed.ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default); + Assert.True(await feed.AckAsync(AccountMergeNoticeTarget.Chat, before.Id, AccountMergeNoticeKind.Merged, default)); + Assert.True(await feed.AckAsync(AccountMergeNoticeTarget.Chat, before.Id, AccountMergeNoticeKind.Merged, default)); + Assert.False(await feed.AckAsync(AccountMergeNoticeTarget.Chat, before.Id, AccountMergeNoticeKind.Final, default)); + + await using var fresh = world.Db(); + var after = await fresh.AccountMerges.AsNoTracking().SingleAsync(); + Assert.Equal(System.Text.Json.JsonSerializer.Serialize(before), System.Text.Json.JsonSerializer.Serialize(after)); + Assert.Equal(1, await fresh.AccountMergeNoticeAcks.CountAsync()); + } + + // ---- the unique key, on a real database ---- + + [PostgresFact] + public async Task Two_concurrent_acks_of_one_notice_both_succeed_and_leave_one_row() + { + var name = $"bifrost_notices_{Guid.NewGuid():N}"; + var admin = Environment.GetEnvironmentVariable(PostgresGate.Variable)!; + await using (var connection = new Npgsql.NpgsqlConnection(admin)) + { + await connection.OpenAsync(); + await using var create = new Npgsql.NpgsqlCommand($"CREATE DATABASE \"{name}\"", connection); + await create.ExecuteNonQueryAsync(); + } + + try + { + var (world, _, _) = await SeededAsync((s, f) => [Committed(s, f, T0)], new Npgsql.NpgsqlConnectionStringBuilder(admin) { Database = name }.ConnectionString); + Guid id; + await using (var read = world.Db()) + { + id = (await read.AccountMerges.SingleAsync()).Id; + } + + for (var round = 0; round < 5; round++) // a fresh race each round + { + const AccountMergeNoticeKind kind = AccountMergeNoticeKind.Merged; + var results = await Task.WhenAll(Enumerable.Range(0, 6).Select(async _ => + { + await using var db = world.Db(); + return await Feed(db).AckAsync(AccountMergeNoticeTarget.Chat, id, kind, default); + })); + Assert.All(results, Assert.True); + } + + await using var check = world.Db(); + Assert.Equal(1, await check.AccountMergeNoticeAcks.CountAsync()); + Assert.Empty(await Feed(check).ReadAsync(AccountMergeNoticeTarget.Chat, null, null, default)); + } + finally + { + Npgsql.NpgsqlConnection.ClearAllPools(); + await using var connection = new Npgsql.NpgsqlConnection(admin); + await connection.OpenAsync(); + await using var drop = new Npgsql.NpgsqlCommand($"DROP DATABASE IF EXISTS \"{name}\" WITH (FORCE)", connection); + await drop.ExecuteNonQueryAsync(); + } + } +} diff --git a/tests/Bifrost.Web.Tests/MergePreviewTests.cs b/tests/Bifrost.Web.Tests/MergePreviewTests.cs new file mode 100644 index 0000000..3e9d8c8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergePreviewTests.cs @@ -0,0 +1,106 @@ +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; + +namespace Bifrost.Web.Tests; + +/// The wording of a merge plan: closed vocabulary, counts from the plan's entries, the statements a member must always see. +public sealed class MergePreviewTests +{ + private static readonly string[] BlockCodes = + [ + "same_account", "account_missing", "mind_account", "native_account", "merge_in_flight", + "survivor_is_folded", "already_folded", "folded_is_survivor", "sealed_ballot_pending" + ]; + + private static MergePlanEntry Entry(AccountMergeEntryKind kind, AccountMergeEntryAction action) => new(kind, action, Guid.NewGuid()); + + private static MergePlan Plan(IEnumerable entries, IReadOnlyDictionary? left = null) => + new([], [.. entries], left ?? new Dictionary()); + + [Fact] + public void The_provider_conflict_names_the_providers_and_says_to_prove_that_side_again_after_unlinking() + { + // The conflict is read from the login list held with the proofs, which unlinking does not change until the member proves again. + var words = MergePreview.ProviderConflict(["Discord", "Twitch"]); + + Assert.Contains("Discord and Twitch", words); + Assert.Contains("Unlink", words); + Assert.Contains("prove that account again", words); + } + + [Fact] + public void Every_block_code_has_its_own_words_and_none_names_an_account() + { + var words = BlockCodes.Select(MergePreview.Block).ToList(); + + Assert.All(words, x => Assert.NotEmpty(x)); + Assert.Equal(BlockCodes.Length, words.Distinct().Count()); + Assert.All(words, x => Assert.DoesNotContain("@", x)); + Assert.NotEmpty(MergePreview.Block("a code nobody wrote")); + Assert.DoesNotContain("a code nobody wrote", MergePreview.Block("a code nobody wrote")); + } + + [Fact] + public void The_counts_come_from_the_plans_entries_by_kind_and_action() + { + var plan = Plan( + [ + .. Enumerable.Range(0, 3).Select(_ => Entry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided)), + Entry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Moved), + .. Enumerable.Range(0, 2).Select(_ => Entry(AccountMergeEntryKind.ConceptAppraisal, AccountMergeEntryAction.Voided)), + Entry(AccountMergeEntryKind.Membership, AccountMergeEntryAction.Closed), + Entry(AccountMergeEntryKind.Standing, AccountMergeEntryAction.Suspended), + .. Enumerable.Range(0, 4).Select(_ => Entry(AccountMergeEntryKind.PatronSupportEvent, AccountMergeEntryAction.Moved)) + ], + new Dictionary { ["ConsentReceipts"] = 5, ["PasskeyCredentials"] = 1, ["LedgerEntries"] = 0 }); + + var text = string.Join('\n', MergePreview.Lines(plan)); + + Assert.Contains("3 open votes", text); + Assert.Contains("1 open vote ", text); + Assert.Contains("2 appraisals", text); + Assert.Contains("membership", text); + Assert.Contains("suspended", text); + Assert.Contains("4 patron events", text); + Assert.Contains("5 consent records", text); + Assert.Contains("1 passkey ", text); + Assert.DoesNotContain("0 ledger", text); + + Assert.Contains("2 open votes", string.Join('\n', MergePreview.Lines(Plan( + Enumerable.Range(0, 2).Select(_ => Entry(AccountMergeEntryKind.Vote, AccountMergeEntryAction.Voided)))))); + } + + [Theory] + [InlineData("chat id")] + [InlineData("consent again")] + [InlineData("StreamPixels")] + [InlineData("Roles and admin")] + [InlineData("one vote per motion")] + [InlineData("7 days")] + [InlineData("ordinary sign-ins")] + [InlineData("Merge status")] + public void The_statements_always_carry_what_a_merge_cannot_carry_over(string statement) + { + Assert.Contains(MergePreview.Statements, x => x.Contains(statement, StringComparison.Ordinal)); + } + + [Fact] + public void The_statements_promise_no_passkey_because_no_page_offers_one() + { + Assert.DoesNotContain(MergePreview.Statements, x => x.Contains("passkey", StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData("blocked")] + [InlineData("preview_stale")] + [InlineData("not_party")] + [InlineData("proof_account_mismatch")] + [InlineData("proofs_needed")] + [InlineData("consent_needed")] + [InlineData("bad_choice")] + public void Every_refusal_the_page_can_answer_has_closed_words(string code) + { + Assert.NotEmpty(MergeWording.Confirm(code)); + Assert.Empty(MergeWording.Confirm("not a code")); + } +} diff --git a/tests/Bifrost.Web.Tests/MergeProofCompletionTests.cs b/tests/Bifrost.Web.Tests/MergeProofCompletionTests.cs new file mode 100644 index 0000000..704b037 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeProofCompletionTests.cs @@ -0,0 +1,230 @@ +using System.Net; +using System.Text.Json; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// +/// The two merge-proof sign-ins from the browser's side, against a fake Heimdall: a fresh sign-in as the account signed in here (Self) +/// or as a second account (Other), each held in and nowhere else. No page starts them yet, so the tests start +/// them through the service and finish them as the browser does, by posting the completion code with the attempt cookie. +/// +public sealed class MergeProofCompletionTests +{ + private const string Page = "/Membership/MergeStandIn"; + private const string Callback = "https://localhost"; + + private sealed record Member(string Account, string Session, Guid UserAccountId); + + private static async Task SignedIn(HeimdallHost host) + { + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + return new Member(account, session, (await host.Account(account))!.Id); + } + + private static async Task Begin(HeimdallHost host, Member session, MergeSide side, string via = "discord") + { + var auth = host.Services.GetRequiredService(); + var started = side == MergeSide.Self + ? await auth.StartMergeProveSelfAsync(session.Account, via, Page, Callback, [], CancellationToken.None) + : await auth.StartMergeProveOtherAsync(session.Account, via, Page, Callback, [], CancellationToken.None); + var returnTo = JsonDocument.Parse(host.Heimdall.StartBodies[^1]).RootElement.GetProperty("returnTo").GetString()!; + var nonce = QueryHelpers.ParseQuery(new Uri(returnTo).Query)["attempt"].ToString(); + return new StartedAttempt(nonce, started.CookieName, started.CookieValue, returnTo, new HttpResponseMessage()); + } + + private static async Task Finish( + HeimdallHost host, Member session, StartedAttempt attempt, string provedAccount, string token, string provider = "discord", string? sessionOverride = null) + { + var code = host.CodeFor(attempt, provedAccount, token, provider: provider); + return await host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = code }, + $"{BifrostCookies.Session}={sessionOverride ?? session.Session}; {attempt.CookieName}={attempt.Cookie}"); + } + + private static string? Location(HttpResponseMessage response) => response.Headers.Location?.OriginalString; + + private static MergeProofs Proofs(HeimdallHost host) => host.Services.GetRequiredService(); + + private static void SetList(HeimdallHost host, params string[] providers) + { + host.Heimdall.Identities.Clear(); + foreach (var provider in providers) + { + host.Heimdall.AddIdentity(provider, "Name of " + provider); + } + } + + [Theory] + [InlineData(MergeSide.Self)] + [InlineData(MergeSide.Other)] + public async Task A_completion_through_another_provider_than_the_attempts_records_no_proof(MergeSide side) + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + var other = await SignedIn(host); + SetList(host, "discord", "patreon"); + var proved = side == MergeSide.Self ? me : other; + var attempt = await Begin(host, me, side, via: "discord"); + + var done = await Finish(host, me, attempt, proved.Account, host.Token(proved.Account), provider: "patreon"); + + Assert.NotEqual(Page + "?result=merge_proof_recorded", Location(done)); + var held = Proofs(host).Snapshot(me.UserAccountId); + Assert.Null(held.Self); + Assert.Null(held.Other); + } + + [Fact] + public async Task A_self_proof_for_the_signed_in_account_is_held_and_the_browser_goes_back_with_a_closed_code() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + SetList(host, "discord", "patreon"); + var bearer = host.Token(me.Account); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, bearer); + + Assert.Equal(Page + "?result=merge_proof_recorded", Location(done)); + var view = Assert.IsType(Proofs(host).Snapshot(me.UserAccountId).Self); + Assert.Equal(me.UserAccountId, view.UserAccountId); + Assert.Equal(["discord"], view.ProvedProviders); + Assert.Equal(["discord", "patreon"], view.LoginProviders.Order().ToArray()); + Assert.Equal($"Bearer {bearer}", Assert.Single(host.Heimdall.ListAuthorizations)); + var surface = Location(done) + string.Join('\n', done.Headers.Select(x => x.Key + string.Concat(x.Value))); + Assert.DoesNotContain(bearer, surface); + Assert.DoesNotContain(me.Account, surface); + Assert.DoesNotContain(bearer, string.Join('\n', host.Logs.Lines)); + } + + [Fact] + public async Task A_self_proof_of_a_different_account_is_refused_and_stores_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + var someoneElse = HeimdallShapes.NewAccountId(); + SetList(host, "discord"); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), someoneElse, host.Token(someoneElse)); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId)); + Assert.Empty(host.Heimdall.ListAuthorizations); + } + + [Fact] + public async Task An_other_proof_of_the_sessions_own_account_is_MergeSameAccount_and_stores_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + SetList(host, "discord"); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Other), me.Account, host.Token(me.Account)); + + Assert.Equal(Page + "?result=merge_same_account", Location(done)); + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId)); + Assert.Empty(host.Heimdall.ListAuthorizations); + } + + [Fact] + public async Task An_other_proof_for_a_Heimdall_account_without_a_member_account_is_MergeNoAccount_and_the_bearer_is_dropped() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + var stranger = HeimdallShapes.NewAccountId(); + SetList(host, "patreon"); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Other, "patreon"), stranger, host.Token(stranger), "patreon"); + + Assert.Equal(Page + "?result=merge_no_account", Location(done)); + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId)); + Assert.Empty(host.Heimdall.ListAuthorizations); + Assert.Null(await host.Account(stranger)); + } + + [Fact] + public async Task Both_sides_proved_give_the_actor_the_bearers_that_were_redeemed() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + var them = await SignedIn(host); + SetList(host, "discord"); + var mine = host.Token(me.Account); + await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, mine); + SetList(host, "patreon"); + var theirs = host.Token(them.Account); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Other, "patreon"), them.Account, theirs, "patreon"); + + Assert.Equal(Page + "?result=merge_proof_recorded", Location(done)); + var ready = Assert.IsType(Proofs(host).Peek(me.UserAccountId, MergeSide.Self)); + Assert.Equal((mine, me.Account, "discord"), (ready.Survivor.AccessToken, ready.Survivor.HeimdallAccountId, ready.Survivor.Provider)); + var folded = Assert.Single(ready.Folded); + Assert.Equal((theirs, them.Account, "patreon"), (folded.AccessToken, folded.HeimdallAccountId, folded.Provider)); + var view = Assert.IsType(Proofs(host).Snapshot(me.UserAccountId).Other); + Assert.Equal(them.UserAccountId, view.UserAccountId); + } + + [Fact] + public async Task A_list_Heimdall_does_not_give_stores_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + SetList(host, "discord"); + host.Heimdall.ListStatus = HttpStatusCode.InternalServerError; + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, host.Token(me.Account)); + + Assert.Equal(Page + "?result=error", Location(done)); + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId)); + } + + [Fact] + public async Task A_side_holding_two_logins_of_one_provider_is_MergeLoginsAmbiguous_and_stores_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + SetList(host, "discord", "discord"); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, host.Token(me.Account)); + + Assert.Equal(Page + "?result=merge_logins_ambiguous", Location(done)); + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId)); + } + + [Fact] + public async Task A_completion_in_another_browsers_session_holds_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + var them = await SignedIn(host); + SetList(host, "discord"); + + var done = await Finish(host, me, await Begin(host, me, MergeSide.Self), me.Account, host.Token(me.Account), sessionOverride: them.Session); + + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(me.UserAccountId)); + Assert.Equal(new MergeProofsView(null, null), Proofs(host).Snapshot(them.UserAccountId)); + Assert.Empty(host.Heimdall.ListAuthorizations); + Assert.Equal(Page + "?result=error", Location(done)); + } + + [Fact] + public async Task A_merge_proof_starts_only_through_a_provider_the_member_names() + { + using var host = new HeimdallHost(realSessions: true); + var me = await SignedIn(host); + var auth = host.Services.GetRequiredService(); + var before = host.Heimdall.StartBodies.Count; + + await Assert.ThrowsAsync(() => auth.StartMergeProveSelfAsync(me.Account, null, Page, Callback, [], CancellationToken.None)); + await Assert.ThrowsAsync(() => auth.StartMergeProveOtherAsync(me.Account, "github", Page, Callback, [], CancellationToken.None)); + + Assert.Equal(before, host.Heimdall.StartBodies.Count); + } +} diff --git a/tests/Bifrost.Web.Tests/MergeProofsTests.cs b/tests/Bifrost.Web.Tests/MergeProofsTests.cs new file mode 100644 index 0000000..5c28e48 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeProofsTests.cs @@ -0,0 +1,243 @@ +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +/// The in-memory holder of merge proofs: per actor, expiring, never consuming, bearer-free in every view. +public sealed class MergeProofsTests +{ + private const string Secret = "BEARER-CANARY"; + + private readonly TestClock _clock = new(); + private readonly MergeProofs _proofs; + private readonly Guid _actor = Guid.NewGuid(); + private readonly Guid _self = Guid.NewGuid(); + private readonly Guid _other = Guid.NewGuid(); + private readonly string _selfAccount = HeimdallShapes.NewAccountId(); + private readonly string _otherAccount = HeimdallShapes.NewAccountId(); + + public MergeProofsTests() => _proofs = new MergeProofs(_clock); + + private static LinkedIdentitySummary Login(string provider) => new(Guid.NewGuid().ToString(), provider, "Name of " + provider, null); + + private MergeProof Proof(string account, string provider, string? token = null) => new(token ?? Secret + provider, account, provider); + + private PutOutcome PutSelf(string provider, params LinkedIdentitySummary[] logins) => + _proofs.Put(_actor, MergeSide.Self, Proof(_selfAccount, provider), _self, logins); + + private PutOutcome PutOther(string provider, params LinkedIdentitySummary[] logins) => + _proofs.Put(_actor, MergeSide.Other, Proof(_otherAccount, provider), _other, logins); + + [Fact] + public void Peek_gives_one_actor_their_own_proofs_and_never_consumes() + { + var selfLogin = Login("discord"); + var otherLogin = Login("patreon"); + Assert.Equal(PutOutcome.Stored, PutSelf("discord", selfLogin)); + Assert.Equal(PutOutcome.Stored, PutOther("patreon", otherLogin)); + + var first = Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + var second = Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + + Assert.Equal("discord", first.Survivor.Provider); + Assert.Equal("patreon", Assert.Single(first.Folded).Provider); + Assert.Equal(first.Survivor, second.Survivor); + Assert.Equal(first.Folded, second.Folded); + Assert.IsType(_proofs.Peek(Guid.NewGuid(), MergeSide.Self)); + Assert.Null(_proofs.Snapshot(Guid.NewGuid()).Self); + Assert.Null(_proofs.Snapshot(Guid.NewGuid()).Other); + } + + [Fact] + public void Peek_runs_both_ways_the_survivor_is_chosen_by_the_caller() + { + PutSelf("discord", Login("discord")); + PutOther("patreon", Login("patreon")); + + var ready = Assert.IsType(_proofs.Peek(_actor, MergeSide.Other)); + + Assert.Equal(_otherAccount, ready.Survivor.HeimdallAccountId); + Assert.Equal(_selfAccount, Assert.Single(ready.Folded).HeimdallAccountId); + } + + [Fact] + public void Clear_removes_both_sides_and_only_that_actors() + { + PutSelf("discord", Login("discord")); + PutOther("patreon", Login("patreon")); + var stranger = Guid.NewGuid(); + _proofs.Put(stranger, MergeSide.Self, Proof(_selfAccount, "discord"), _self, [Login("discord")]); + + _proofs.Clear(_actor); + + Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + Assert.Equal(new MergeProofsView(null, null), _proofs.Snapshot(_actor)); + Assert.NotNull(_proofs.Snapshot(stranger).Self); + } + + [Theory] + [InlineData(269, true)] + [InlineData(270, false)] + [InlineData(271, false)] + public void A_proof_lives_270_seconds_from_its_redeem(int seconds, bool alive) + { + PutSelf("discord", Login("discord")); + PutOther("patreon", Login("patreon")); + + _clock.Advance(TimeSpan.FromSeconds(seconds)); + + Assert.Equal(alive, _proofs.Peek(_actor, MergeSide.Self) is PeekResult.Ready); + Assert.Equal(alive, _proofs.Snapshot(_actor).Self is not null); + } + + [Fact] + public void One_lapsed_proof_costs_only_that_provider_and_a_newer_proof_replaces_the_older() + { + var discord = Login("discord"); + var patreon = Login("patreon"); + PutSelf("twitch", Login("twitch")); + PutOther("discord", discord, patreon); + _clock.Advance(TimeSpan.FromSeconds(200)); + PutOther("patreon", discord, patreon); + _clock.Advance(TimeSpan.FromSeconds(100)); // the discord proof is 300 s old, the patreon one 100 s + + var self = PutSelf("twitch", Login("twitch")); // keeps the self side alive + var unproved = Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + + Assert.Equal(PutOutcome.Stored, self); + Assert.Equal(["discord"], unproved.Providers); + + PutOther("discord", discord, patreon); + var ready = Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + Assert.Equal(["discord", "patreon"], ready.Folded.Select(x => x.Provider).Order().ToArray()); + var replacedAt = Assert.IsType(_proofs.Snapshot(_actor).Other); + Assert.Equal(["discord", "patreon"], replacedAt.ProvedProviders.Order().ToArray()); + } + + [Fact] + public void A_login_list_with_two_logins_of_one_provider_is_refused_and_stores_nothing() + { + var outcome = PutOther("discord", Login("discord"), Login("discord")); + + Assert.Equal(PutOutcome.AmbiguousLogins, outcome); + Assert.Null(_proofs.Snapshot(_actor).Other); + } + + [Fact] + public void A_folded_side_with_an_unproved_provider_names_providers_and_nothing_else() + { + PutSelf("discord", Login("discord")); + PutOther("patreon", Login("patreon"), Login("twitch"), Login("google")); + + var unproved = Assert.IsType(_proofs.Peek(_actor, MergeSide.Self)); + + Assert.Equal(["google", "twitch"], unproved.Providers.Order().ToArray()); + Assert.DoesNotContain(Secret, unproved.ToString()); + } + + [Fact] + public void A_proof_for_another_account_on_a_side_replaces_the_side_and_the_old_proofs_are_not_mixed_in() + { + PutOther("patreon", Login("patreon"), Login("twitch")); + var someoneElse = Guid.NewGuid(); + var elsewhere = HeimdallShapes.NewAccountId(); + + _proofs.Put(_actor, MergeSide.Other, Proof(elsewhere, "twitch"), someoneElse, [Login("twitch")]); + + var view = Assert.IsType(_proofs.Snapshot(_actor).Other); + Assert.Equal(someoneElse, view.UserAccountId); + Assert.Equal(["twitch"], view.ProvedProviders); + } + + [Fact] + public void A_side_holds_at_most_eight_proofs() + { + var providers = Enumerable.Range(0, MergeProofs.MaxProofsPerSide + 1).Select(x => "p" + x).ToArray(); + var logins = providers.Select(Login).ToArray(); + + var outcomes = providers.Select(p => PutOther(p, logins)).ToArray(); + + Assert.All(outcomes.Take(MergeProofs.MaxProofsPerSide), x => Assert.Equal(PutOutcome.Stored, x)); + Assert.Equal(PutOutcome.TooManyProofs, outcomes[^1]); + Assert.Equal(MergeProofs.MaxProofsPerSide, _proofs.Snapshot(_actor).Other!.ProvedProviders.Count); + } + + [Fact] + public void Undo_needs_every_moved_login_proved_and_returns_only_the_selfs_proofs() + { + var discord = Login("discord"); + var patreon = Login("patreon"); + var twitch = Login("twitch"); + PutSelf("discord", discord, patreon, twitch); + PutOther("twitch", Login("twitch")); + + var missing = Assert.IsType(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id])); + PutSelf("patreon", discord, patreon, twitch); + var ready = Assert.IsType(_proofs.PeekUndo(_actor, [discord.Id, patreon.Id])); + + Assert.Equal(["patreon"], missing.Providers); + Assert.Equal(["discord", "patreon"], ready.Proofs.Select(x => x.Provider).Order().ToArray()); + Assert.All(ready.Proofs, x => Assert.Equal(_selfAccount, x.HeimdallAccountId)); + Assert.IsType(_proofs.PeekUndo(_actor, [discord.Id, Guid.NewGuid().ToString()])); + Assert.IsType(_proofs.PeekUndo(Guid.NewGuid(), [discord.Id])); + } + + [Fact] + public void No_view_or_printed_result_holds_a_bearer() + { + PutSelf("discord", Login("discord")); + PutOther("patreon", Login("patreon")); + var ready = _proofs.Peek(_actor, MergeSide.Self); + + var printed = string.Join('\n', _proofs.Snapshot(_actor), ready, ready is PeekResult.Ready r ? r.Survivor : null, _proofs.PeekUndo(_actor, ["x"])); + + Assert.DoesNotContain(Secret, printed); + Assert.DoesNotContain(_selfAccount, printed); + Assert.DoesNotContain(_otherAccount, printed); + } + + /// Actors with anything in memory: pruning drops a side with no proof and an actor with no side. + private int HeldProofs() => + ((System.Collections.IDictionary)typeof(MergeProofs).GetField("_held", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(_proofs)!) + .Count; + + [Fact] + public void An_expired_bearer_is_dropped_by_any_later_access_even_one_for_another_actor() + { + PutSelf("discord", Login("discord")); + Assert.Equal(1, HeldProofs()); + _clock.Advance(MergeProofs.ProofLifetime + TimeSpan.FromSeconds(1)); + + _proofs.Clear(Guid.NewGuid()); + + Assert.Equal(0, HeldProofs()); + } + + [Fact] + public void PruneExpired_drops_an_expired_bearer_with_no_other_access_and_keeps_a_live_one() + { + PutSelf("discord", Login("discord")); + _clock.Advance(MergeProofs.ProofLifetime); + _proofs.PruneExpired(); + Assert.Equal(0, HeldProofs()); + + PutSelf("discord", Login("discord")); + _clock.Advance(MergeProofs.ProofLifetime - TimeSpan.FromSeconds(1)); + _proofs.PruneExpired(); + Assert.Equal(1, HeldProofs()); + } + + [Fact] + public void Two_sides_holding_a_login_of_one_provider_are_a_conflict_named_by_provider_only_in_both_directions() + { + PutSelf("discord", Login("discord"), Login("twitch")); + PutOther("patreon", Login("patreon"), Login("discord"), Login("twitch")); + + foreach (var survivor in new[] { MergeSide.Self, MergeSide.Other }) + { + var conflict = Assert.IsType(_proofs.Peek(_actor, survivor)); + Assert.Equal(["discord", "twitch"], conflict.Providers); + } + } +} diff --git a/tests/Bifrost.Web.Tests/MergeStatusTests.cs b/tests/Bifrost.Web.Tests/MergeStatusTests.cs new file mode 100644 index 0000000..780c151 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeStatusTests.cs @@ -0,0 +1,222 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Tests.Support; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static Bifrost.Web.Tests.AccountMergeSagaTests; + +namespace Bifrost.Web.Tests; + +/// +/// What the merge pages show each member, from browser sessions of the real host over the saga rig: the notice in the layout and the +/// page itself, for the survivor inside the grace, for a frozen or folded account, and for everyone else (nothing). +/// +public sealed class MergeStatusTests +{ + private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text); + + private static int Notices(string html) => Regex.Matches(html, "class=\"merge-notice\"").Count; + + private sealed record World(Rig Rig, Party Survivor, Party Folded, Party Stranger, string SurvivorSession, string FoldedSession, string StrangerSession, Guid MergeId); + + private static async Task Open(string[]? foldedLogins = null) + { + var rig = new Rig(realSessions: true); + var survivor = await rig.AccountAsync("quill-9f3a", "twitch"); + var folded = await rig.AccountAsync("banks-2d7c", foldedLogins ?? ["discord"]); + var stranger = await rig.AccountAsync("stranger-51e0", "twitch"); + // Sessions exist before the merge, as a member's browser does. + var sessions = new[] { survivor, folded, stranger }; + var cookies = new List(); + foreach (var who in sessions) + { + cookies.Add(await rig.Host.SignInSessionAsync(who.HeimdallId)); + } + + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), [.. (foldedLogins ?? ["discord"]).Select(x => rig.Weak(folded, x))]); + return new World(rig, survivor, folded, stranger, cookies[0], cookies[1], cookies[2], id); + } + + private static async Task Committed(string[]? foldedLogins = null) + { + var world = await Open(foldedLogins); + Assert.Equal(AccountMergeState.Committed, (await world.Rig.AdvanceAsync(world.MergeId)).State); + return world; + } + + private static async Task Html(World world, string session, string url = MergePage.Url) + { + using var client = world.Rig.Host.NewClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("Cookie", $"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}"); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + + private static async Task Edit(World world, Action change) + { + using var scope = world.Rig.Host.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var merge = await db.AccountMerges.SingleAsync(x => x.Id == world.MergeId); + change(merge); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task The_survivor_sees_days_left_and_the_weak_note_on_the_page_and_one_notice_in_the_layout() + { + var w = await Committed(); + using var rig = w.Rig; + rig.Clock.Advance(TimeSpan.FromDays(2)); + var merge = await rig.MergeAsync(w.MergeId); + + var page = await Html(w, w.SurvivorSession); + + Assert.Contains(Enc(MergeWording.DaysLeft(5)), page); + Assert.Contains(Enc(MergeWording.TierNote("weak", merge.GraceEndsUtc!.Value)), page); + Assert.DoesNotContain(Enc(MergeWording.TierNote("strong", merge.GraceEndsUtc!.Value)), page); + Assert.Contains(Enc(MergeWording.State(AccountMergeState.Committed)), page); + Assert.Equal(1, Notices(page)); + Assert.Contains(Enc(MergeWording.SurvivorNotice(5)), page); + Assert.Equal(1, Notices(await Html(w, w.SurvivorSession, "/Membership/Status"))); + } + + [Fact] + public async Task Past_the_grace_and_before_the_sweeper_there_is_no_notice_and_no_undo_offer() + { + var w = await Committed(); + using var rig = w.Rig; + var inside = await Html(w, w.SurvivorSession); + Assert.Equal(1, Notices(inside)); + Assert.Contains("To undo, sign in again", inside); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromHours(1)); + Assert.Equal(AccountMergeState.Committed, (await rig.MergeAsync(w.MergeId)).State); + var late = await Html(w, w.SurvivorSession); + + Assert.Equal(0, Notices(late)); + Assert.DoesNotContain("To undo, sign in again", late); + Assert.DoesNotContain("Undo this merge", late); + Assert.DoesNotContain("ProveForUndo", late); + Assert.DoesNotContain("can still be undone", late); + } + + [Fact] + public async Task Once_the_undo_is_requested_no_page_says_the_merge_can_still_be_undone() + { + var w = await Committed(); + using var rig = w.Rig; + await Edit(w, x => x.State = AccountMergeState.UndoRequested); + + var page = await Html(w, w.SurvivorSession); + + Assert.Equal(1, Notices(page)); + Assert.DoesNotContain("can still be undone", page); + Assert.DoesNotContain("can still undo", page); + Assert.Contains("Undo requested", page); + Assert.DoesNotContain("To undo, sign in again", page); + } + + [Fact] + public async Task A_strong_proof_merge_says_so_in_its_own_words() + { + var w = await Committed(); + using var rig = w.Rig; + await Edit(w, x => x.ProofFromTier = "strong"); + var merge = await rig.MergeAsync(w.MergeId); + + var page = await Html(w, w.SurvivorSession); + + Assert.NotEqual(MergeWording.TierNote("weak", merge.GraceEndsUtc!.Value), MergeWording.TierNote("strong", merge.GraceEndsUtc!.Value)); + Assert.Contains(Enc(MergeWording.TierNote("strong", merge.GraceEndsUtc!.Value)), page); + } + + [Fact] + public async Task No_notice_for_a_merge_that_is_Final_Undone_or_Failed_and_none_for_another_member() + { + var w = await Committed(); + using var rig = w.Rig; + Assert.Equal(1, Notices(await Html(w, w.SurvivorSession))); + Assert.Equal(0, Notices(await Html(w, w.StrangerSession))); + Assert.DoesNotContain(Enc(MergeWording.State(AccountMergeState.Committed)), await Html(w, w.StrangerSession)); + + rig.Clock.Advance(AccountMergeLedger.Grace + TimeSpan.FromHours(1)); + await rig.InScopeAsync(sp => sp.GetRequiredService().FinalizeDueAsync(rig.Clock.Now, default)); + var final = await Html(w, w.SurvivorSession); + Assert.Equal(AccountMergeState.Final, (await rig.MergeAsync(w.MergeId)).State); + Assert.Equal(0, Notices(final)); + Assert.Contains("/Membership/MergeStatus", await Html(w, w.SurvivorSession, "/Membership/Status")); + Assert.DoesNotContain("/Membership/MergeStatus", await Html(w, w.StrangerSession, "/Membership/Status")); + Assert.Contains(Enc(MergeWording.State(AccountMergeState.Final)), final); + + foreach (var state in new[] { AccountMergeState.Undone, AccountMergeState.Failed }) + { + await Edit(w, x => { x.State = state; x.FinalizedUtc = null; }); + var page = await Html(w, w.SurvivorSession); + Assert.Equal(0, Notices(page)); + Assert.Contains(Enc(MergeWording.State(state)), page); + } + } + + [Theory] + [InlineData(AccountMergeState.Undone)] + [InlineData(AccountMergeState.Failed)] + [InlineData(AccountMergeState.Final)] + public async Task A_merge_that_ended_has_no_notice_even_with_its_grace_date_still_ahead(AccountMergeState state) + { + var w = await Committed(); + using var rig = w.Rig; + await Edit(w, x => x.State = state); + + var page = await Html(w, w.SurvivorSession); + + Assert.Equal(0, Notices(page)); + Assert.Contains(Enc(MergeWording.State(state)), page); + } + + [Fact] + public async Task A_failed_merge_says_why_in_closed_words_and_never_echoes_the_stored_text() + { + var w = await Committed(); + using var rig = w.Rig; + await Edit(w, x => { x.State = AccountMergeState.Failed; x.FailureCode = "proof_stale"; x.LastOutcome = "HEIMDALL FREE TEXT"; }); + + var page = await Html(w, w.SurvivorSession); + + Assert.Contains(Enc(MergeWording.Failure("proof_stale")!), page); + Assert.Contains(Enc(MergeWording.Outcome("HEIMDALL FREE TEXT")!), page); + Assert.DoesNotContain("HEIMDALL FREE TEXT", page); + Assert.DoesNotContain("proof_stale", page); + } + + [Fact] + public async Task A_frozen_account_reaches_the_page_and_is_told_the_merge_is_in_progress() + { + var w = await Open(); + using var rig = w.Rig; + + var page = await Html(w, w.FoldedSession); + + Assert.Equal(1, Notices(page)); + Assert.Contains(Enc(MergeWording.FrozenNotice()), page); + Assert.Contains(Enc(MergeWording.State(AccountMergeState.Confirmed)), page); + } + + [Fact] + public async Task A_folded_account_reaches_the_page_is_told_when_it_was_merged_and_cannot_undo() + { + var w = await Committed(); + using var rig = w.Rig; + var merge = await rig.MergeAsync(w.MergeId); + + var page = await Html(w, w.FoldedSession); + + Assert.Equal(1, Notices(page)); + Assert.Contains(Enc(MergeWording.FoldedNotice(merge.CommittedUtc!.Value)), page); + Assert.DoesNotContain(" +/// The merge status page's undo from the person's side: browser sessions of the real host over the saga rig and the fake Heimdall's +/// account model. The folded person signs in through a login the merge moved and is the survivor there. +///
+public sealed class MergeUndoPageTests +{ + private const string Callback = "https://localhost"; + + /// The Heimdall account that holds a login now, by the fake's own model of where a move left it. + private static string OwnerOf(Rig rig, string loginId) => + rig.Moves.Logins.Single(x => x.Value.Any(l => l.Id == loginId)).Key; + + private sealed class World(Rig rig, Party survivor, Party folded, Guid mergeId) : IDisposable + { + public Rig Rig { get; } = rig; + + public Party Survivor { get; } = survivor; + + public Party Folded { get; } = folded; + + public Guid MergeId { get; } = mergeId; + + public HeimdallHost Host => Rig.Host; + + /// Everything a response put on the wire: bodies, Location, Set-Cookie. The privacy test reads it at the end. + public List Surface { get; } = []; + + /// Every bearer the test redeemed; none may appear on the surface. + public List Bearers { get; } = []; + + public void Dispose() => Rig.Dispose(); + + public async Task SignInThrough(string provider) => await Host.SignInSessionAsync(OwnerOf(Rig, Folded.Logins[provider]), provider); + + public async Task<(MergePage.Antiforgery Token, HttpResponseMessage Response)> Post(string session, MergePage.Antiforgery? token, string handler, params (string, string)[] form) + { + token ??= await MergePage.TokenAsync(Host, session); + var response = await MergePage.PostAsync(Host, session, token, handler, form.ToDictionary(x => x.Item1, x => x.Item2)); + Record(response, string.Empty); + return (token, response); + } + + public void Record(HttpResponseMessage response, string body) => + Surface.Add(body + "\n" + MergePage.Location(response) + "\n" + string.Join('\n', response.Headers.Select(x => x.Key + string.Concat(x.Value)))); + + public async Task Page(string session, string query = "") + { + var (response, html) = await MergePage.GetAsync(Host, session, query); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Record(response, html); + return html; + } + + public IReadOnlyList MovedIds(AccountMerge merge) => JsonSerializer.Deserialize(merge.MovedIdentityIds!)!; + + /// One fresh sign-in of the survivor's account through a moved login of , as the page's button and Heimdall's return do. + public async Task Prove(string session, MergePage.Antiforgery token, string provider) + { + var merge = await Rig.MergeAsync(MergeId); + var before = Host.Heimdall.StartBodies.Count; + var (_, started) = await Post(session, token, "ProveForUndo", ("via", provider)); + Assert.Equal(HttpStatusCode.Redirect, started.StatusCode); + var attempt = Host.AttemptFrom(started, before); + Assert.NotEmpty(attempt.Nonce); + + Host.Heimdall.Identities.Clear(); + Host.Heimdall.AddIdentity("twitch", id: Survivor.Logins["twitch"]); + foreach (var moved in MovedIds(merge)) + { + Host.Heimdall.AddIdentity(Rig.Moves.Logins.Values.SelectMany(x => x).Single(x => x.Id == moved).Provider, id: moved); + } + + var bearer = Host.Token(Survivor.HeimdallId); + Rig.Moves.Issue(Survivor.HeimdallId, Folded.Logins[provider], bearer: bearer); + Bearers.Add(bearer); + var done = await Host.PostCompleteAsync( + new Dictionary { ["attempt"] = attempt.Nonce, ["code"] = Host.CodeFor(attempt, Survivor.HeimdallId, bearer, provider: provider) }, + $"{Bifrost.Web.Features.Shared.BifrostCookies.Session}={session}; {attempt.CookieName}={attempt.Cookie}"); + Record(done, string.Empty); + Assert.Equal(MergePage.Url + "?result=merge_proof_recorded", MergePage.Location(done)); + } + } + + private static async Task Committed(params string[] foldedLogins) + { + var rig = new Rig(realSessions: true); + var survivor = await rig.AccountAsync("quill-9f3a", "twitch"); + var folded = await rig.AccountAsync("banks-2d7c", foldedLogins); + var id = await rig.ConfirmedAsync(survivor, folded, rig.Weak(survivor, "twitch"), [.. foldedLogins.Select(x => rig.Weak(folded, x))]); + Assert.Equal(AccountMergeState.Committed, (await rig.AdvanceAsync(id)).State); + return new World(rig, survivor, folded, id); + } + + private static string Undo(World w, string code) => $"{MergePage.Url}?undo={code}"; + + [Fact] + public async Task A_sign_in_through_a_login_the_merge_moved_lands_on_the_survivors_account_not_the_folded_one() + { + using var w = await Committed("discord"); + + // Heimdall answers a sign-in through the moved login with the account that holds the login now. + var landedOn = OwnerOf(w.Rig, w.Folded.Logins["discord"]); + Assert.Equal(w.Survivor.HeimdallId, landedOn); + var session = await w.Host.SignInSessionAsync(landedOn, "discord"); + + // The survivor acts (the folded account would be refused on this page: its actor has no account). + var (response, _) = await w.Host.GetLinkedAccountsAsync(session); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Undo_needs_a_fresh_proof_of_every_moved_login_and_the_missing_provider_is_named() + { + using var w = await Committed("discord", "patreon"); + var session = await w.SignInThrough("discord"); + var token = await MergePage.TokenAsync(w.Host, session); + + await w.Prove(session, token, "discord"); + var page = await w.Page(session); + Assert.Contains("Patreon", page); + var asked = w.Rig.Moves.Requests.Count; + + var (_, refused) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString())); + + Assert.Equal(Undo(w, "logins_unproved"), MergePage.Location(refused)); + Assert.Equal(asked, w.Rig.Moves.Requests.Count); + var merge = await w.Rig.MergeAsync(w.MergeId); + Assert.Equal(AccountMergeState.Committed, merge.State); + Assert.Null(merge.UndoRequestedUtc); + Assert.Contains(Enc(MergeWording.Undo("logins_unproved")), await w.Page(session, "?undo=logins_unproved")); + } + + [Fact] + public async Task The_folded_person_proves_every_moved_login_and_the_undo_runs_once_to_Undone_and_spends_the_proofs() + { + using var w = await Committed("discord", "patreon"); + var session = await w.SignInThrough("patreon"); + var token = await MergePage.TokenAsync(w.Host, session); + await w.Prove(session, token, "discord"); + await w.Prove(session, token, "patreon"); + var asked = w.Rig.Moves.Requests.Count; + + var (_, done) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString())); + + Assert.Equal(Undo(w, "requested"), MergePage.Location(done)); + Assert.Equal(AccountMergeState.Undone, (await w.Rig.MergeAsync(w.MergeId)).State); + Assert.Equal(asked + 1, w.Rig.Moves.Requests.Count); + Assert.Equal(new MergeProofsView(null, null), w.Host.Services.GetRequiredService().Snapshot(w.Survivor.Id)); + + // A replay has no proofs left and starts nothing. + var (_, replay) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString())); + Assert.NotEqual(Undo(w, "requested"), MergePage.Location(replay)); + Assert.Equal(asked + 1, w.Rig.Moves.Requests.Count); + } + + [Fact] + public async Task Another_member_posting_the_survivors_undo_is_NotSurvivor_and_the_saga_is_never_asked() + { + using var w = await Committed("discord"); + var other = await w.Rig.AccountAsync("other-survivor-77c1", "twitch"); + var otherFolded = await w.Rig.AccountAsync("other-folded-08ab", "patreon"); + var otherMerge = await w.Rig.ConfirmedAsync(other, otherFolded, w.Rig.Weak(other, "twitch"), w.Rig.Weak(otherFolded, "patreon")); + await w.Rig.AdvanceAsync(otherMerge); + var session = await w.Host.SignInSessionAsync(other.HeimdallId); + var asked = w.Rig.Moves.Requests.Count; + + var (_, response) = await w.Post(session, null, "Undo", ("mergeId", w.MergeId.ToString())); + + Assert.Equal(Undo(w, "not_survivor"), MergePage.Location(response)); + Assert.Equal(asked, w.Rig.Moves.Requests.Count); + var merge = await w.Rig.MergeAsync(w.MergeId); + Assert.Equal(AccountMergeState.Committed, merge.State); + Assert.Null(merge.UndoRequestedUtc); + } + + [Fact] + public async Task A_post_after_the_grace_ended_is_GraceOver_with_live_proofs_and_nothing_is_requested() + { + using var w = await Committed("discord"); + var session = await w.SignInThrough("discord"); + var token = await MergePage.TokenAsync(w.Host, session); + w.Rig.Clock.Advance(AccountMergeLedger.Grace - TimeSpan.FromSeconds(60)); + await w.Prove(session, token, "discord"); + w.Rig.Clock.Advance(TimeSpan.FromSeconds(61)); + var asked = w.Rig.Moves.Requests.Count; + + var (_, response) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString())); + + Assert.Equal(Undo(w, "grace_over"), MergePage.Location(response)); + Assert.Equal(asked, w.Rig.Moves.Requests.Count); + Assert.Equal(AccountMergeState.Committed, (await w.Rig.MergeAsync(w.MergeId)).State); + } + + [Fact] + public async Task A_reversal_Heimdall_refuses_returns_the_merge_to_Committed_and_the_page_words_the_refusal() + { + using var w = await Committed("discord"); + var session = await w.SignInThrough("discord"); + var token = await MergePage.TokenAsync(w.Host, session); + await w.Prove(session, token, "discord"); + w.Rig.Moves.MoveStatus = HttpStatusCode.BadRequest; + + var (_, response) = await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString())); + + Assert.Equal(Undo(w, "requested"), MergePage.Location(response)); + var merge = await w.Rig.MergeAsync(w.MergeId); + Assert.Equal(AccountMergeState.Committed, merge.State); + Assert.StartsWith("undo_", merge.LastOutcome); + Assert.Contains(Enc(MergeWording.Outcome(merge.LastOutcome)!), await w.Page(session)); + } + + [Fact] + public async Task No_response_cookie_or_redirect_of_the_merge_pages_carries_an_account_id_name_or_bearer() + { + using var w = await Committed("discord", "patreon"); + var session = await w.SignInThrough("discord"); + var token = await MergePage.TokenAsync(w.Host, session); + await w.Page(session); + await w.Prove(session, token, "discord"); + await w.Prove(session, token, "patreon"); + await w.Page(session); + await w.Post(session, token, "Undo", ("mergeId", w.MergeId.ToString())); + await w.Page(session, "?undo=requested"); + + var everything = string.Join('\n', w.Surface); + var secrets = new List { "quill-9f3a", "banks-2d7c", w.Survivor.HeimdallId, w.Folded.HeimdallId }; + foreach (var id in new[] { w.Survivor.Id, w.Folded.Id }) + { + secrets.AddRange([id.ToString(), id.ToString("N")]); + } + + secrets.AddRange(w.Survivor.Logins.Values); + secrets.AddRange(w.Folded.Logins.Values); + secrets.AddRange(w.Bearers); + Assert.Equal(2, w.Bearers.Count); + Assert.All(secrets, x => Assert.DoesNotContain(x, everything)); + Assert.DoesNotContain("PUID-CANARY", everything); + Assert.DoesNotContain("username-canary", everything); + } + + private static string Enc(string text) => System.Text.Encodings.Web.HtmlEncoder.Default.Encode(text); +} diff --git a/tests/Bifrost.Web.Tests/MergeWordingTests.cs b/tests/Bifrost.Web.Tests/MergeWordingTests.cs new file mode 100644 index 0000000..6f63e89 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MergeWordingTests.cs @@ -0,0 +1,78 @@ +using System.Text.RegularExpressions; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; + +namespace Bifrost.Web.Tests; + +/// The merge pages' words are a closed set: every state, refusal code and outcome the saga can store has one, and nothing the store holds is echoed. +public sealed class MergeWordingTests +{ + private static string Snake(HeimdallMoveRefusal code) => + code == HeimdallMoveRefusal.RequestInvalid ? "invalid_request" : Regex.Replace(code.ToString(), "(?().Select(MergeWording.State).ToArray(); + Assert.All(words, x => Assert.NotEmpty(x)); + Assert.Equal(words.Length, words.Distinct().Count()); + } + + [Fact] + public void Every_refusal_Heimdall_can_give_has_words_and_the_code_itself_is_not_one_of_them() + { + foreach (var code in Enum.GetValues().Select(Snake)) + { + var words = MergeWording.Failure(code); + Assert.False(string.IsNullOrWhiteSpace(words), code); + Assert.DoesNotContain(code, words!); + Assert.False(string.IsNullOrWhiteSpace(MergeWording.Outcome("undo_" + code)), code); + } + } + + [Theory] + [InlineData("confirmed")] + [InlineData("moved")] + [InlineData("refused")] + [InlineData("undo_requested")] + [InlineData("receipt_mismatch")] + [InlineData("proofs_unreadable")] + [InlineData("transport")] + [InlineData("move_id_conflict")] + [InlineData("malformed or mismatched receipt")] + public void Every_outcome_the_saga_stores_has_words(string outcome) => Assert.False(string.IsNullOrWhiteSpace(MergeWording.Outcome(outcome)), outcome); + + [Theory] + [InlineData("operator_failed", "ended")] + [InlineData("undo_operator_abandoned", "merge stands")] + public void An_operators_settle_has_its_own_words_not_the_retry_line(string outcome, string says) + { + var words = MergeWording.Outcome(outcome); + + Assert.Contains(says, words); + Assert.DoesNotContain("try again", words); + } + + [Theory] + [InlineData("HTTP 502")] + [InlineData("HEIMDALL FREE TEXT")] + [InlineData("undo_made_up_code")] + public void An_outcome_the_wording_does_not_know_is_a_generic_line_that_does_not_echo_it(string outcome) + { + var words = MergeWording.Outcome(outcome); + Assert.False(string.IsNullOrWhiteSpace(words)); + Assert.DoesNotContain(outcome, words!); + Assert.Null(MergeWording.Failure(outcome)); + } + + [Fact] + public void Every_undo_answer_has_distinct_words_and_a_closed_code() + { + UndoOutcome[] all = [new UndoOutcome.Requested(), new UndoOutcome.NotCommitted(), new UndoOutcome.GraceOver(), new UndoOutcome.NotSurvivor(), new UndoOutcome.ProofAccountMismatch()]; + var codes = all.Select(MergeWording.UndoCode).Append("logins_unproved").ToArray(); + Assert.Equal(6, codes.Distinct().Count()); + Assert.All(codes, x => Assert.False(string.IsNullOrWhiteSpace(MergeWording.Undo(x)), x)); + Assert.True(string.IsNullOrEmpty(MergeWording.Undo("anything else"))); + } +} diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 1fc57d2..2085ccc 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -26,6 +26,12 @@ public sealed class MigrationDiscoveryTests "20261109210000_AddMindKeys", "20261111090000_AddConsentReceipts", "20261111100000_ConsentReceiptsOneLivePerVersion", + "20261111110000_AddMembershipSuspensionReason", + "20261111120000_ClearLegacyConsentReceipts", + "20261113120000_AddPasskeyCredentials", + "20261114120000_AddAccountMerges", + "20261114130000_AddAccountMergeSaga", + "20261114140000_AddAccountMergeNoticeAcks", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -55,7 +61,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/NoThirdPartyAssetTests.cs b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs new file mode 100644 index 0000000..1b8d306 --- /dev/null +++ b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs @@ -0,0 +1,133 @@ +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), + ("/Membership/LinkedAccounts", 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"); + } + } +} diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 2b698f0..dfc200a 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -121,6 +121,33 @@ public sealed class OidcIssuerTests : IClassFixture Assert.False(redirect.ContainsKey("code")); } + [Theory] + [InlineData(AccountMergeState.Confirmed)] + [InlineData(AccountMergeState.HeimdallMoved)] + [InlineData(AccountMergeState.Committed)] + [InlineData(AccountMergeState.Final)] + public async Task A_folded_or_frozen_account_is_refused_with_access_denied_and_no_code(AccountMergeState state) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Folded One"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = Guid.NewGuid(), FoldedUserAccountId = _factory.AccountId(identity), State = state, CreatedUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + var request = NewRequest(); + var final = await DriveAsync(client, request); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.Equal("access_denied", redirect["error"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.MergedDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + [Theory] [InlineData(MembershipStatus.Authenticated)] [InlineData(MembershipStatus.PendingApproval)] @@ -224,6 +251,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 +853,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 +1022,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/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/OidcLogoutTests.cs b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs index c167fc2..d3736b8 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -76,6 +76,53 @@ 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 A_folded_subject_gets_one_signed_logout_and_loses_the_authorizations_while_the_survivor_keeps_them() + { + using var factory = new OidcLogoutFactory(); + var (folded, _) = await factory.GrantAsync(MembershipStatus.Active); + var (survivor, _) = await factory.GrantAsync(MembershipStatus.Active); + var foldedSubject = factory.AccountId(folded).ToString("D"); + var survivorSubject = factory.AccountId(survivor).ToString("D"); + Assert.True(factory.ValidAuthorizations(foldedSubject) > 0); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.AccountMerges.Add(new AccountMerge + { + SurvivorUserAccountId = factory.AccountId(survivor), FoldedUserAccountId = factory.AccountId(folded), + State = AccountMergeState.Committed, CreatedUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())); + Assert.Equal(foldedSubject, token.Subject); + Assert.Equal(0, factory.ValidAuthorizations(foldedSubject)); + Assert.True(factory.ValidAuthorizations(survivorSubject) > 0); + } + [Fact] public async Task Two_logouts_for_one_subject_carry_different_jti() { 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(""); + const t = synapse({ media: { abc: { bytes: html } }, events: { [FILE]: { type: "m.room.message", event_id: FILE, room_id: ROOM, sender: OTHER, content: fileContent({ msgtype, info: {} }) } } }); + const m = mouthOn(t); + assert.equal((await m.ask()).outcome, "TypeRefused"); + assert.equal(m.puts.length, 0); + } +}); + +test("every failure word of the image path maps to the enum", async () => { + const cases = [ + [{ abc: { bytes: IMG, status: 500 } }, "FetchFailed"], + [{ abc: { bytes: Buffer.alloc(MAX_BYTES + 1, 1) } }, "TooLarge"], + [{ abc: { bytes: IMG, headers: { "content-length": String(MAX_BYTES + 1) } } }, "TooLarge"], + [{ abc: { bytes: png(10000, 10000) } }, "TooLarge"], + [{ abc: { bytes: png(0, 0) } }, "TypeRefused"], + [{ abc: { bytes: svg } }, "TypeRefused"], + [{ abc: { redirect: true, bytes: IMG } }, "FetchFailed"], + ]; + for (const [media, outcome] of cases) { + const m = mouthOn(synapse({ media })); + const r = await m.ask(); + assert.equal(r.outcome, outcome); + assert.ok(docs.fetchOutcomes.includes(r.outcome)); + assert.equal(m.puts.length, 0); + } + const unwritable = mouthOn(synapse()); + unwritable.puts.length = 0; + const broken = createMouth({ persona: "eureka", homeserver: "http://hs.test", serverName: HS, rooms: [ALIAS], token: TOKEN, fetch: synapse().fetch, state: { load: async () => null, save: async () => {} }, brake: async () => true, images: { put: () => { throw Object.assign(new Error("x"), { code: "ENOSPC" }); } } }); + assert.equal((await broken.fetchAttachment({ requestId: "r", persona: "eureka", room: ALIAS, eventId: FILE })).outcome, "StoreFailed"); +}); + +test("an absent info.sha256 is StoredUnverified under the computed hash with no receipt, no relations read and no send", async () => { + for (const info of [undefined, null, "str", 7, [IMG_SHA], {}, { mimetype: "image/png" }]) { + const s = synapse({ events: { [FILE]: { type: "m.room.message", event_id: FILE, room_id: ROOM, sender: OTHER, content: fileContent({ info }) } } }); + const m = mouthOn(s); + const r = await m.ask(); + assert.equal(r.outcome, "StoredUnverified", JSON.stringify(info)); + assert.equal(r.receipt, null); + assert.equal(r.image.sha256, IMG_SHA); + assert.equal(s.gets(/relations/).length + s.calls.filter(c => c.startsWith("PUT")).length, 0); + assert.match(m.fetchLines()[0], new RegExp(`outcome=StoredUnverified size=${IMG.length} type=image/png sha256=${IMG_SHA} receipt=skipped`)); + } +}); + +test("a present info.sha256 that is not the computed hash is a mismatch: nothing stored, nothing posted, the claim never logged", async () => { + const claims = [null, 7, {}, "", IMG_SHA.slice(1), `${IMG_SHA}0`, "z".repeat(64), IMG_SHA.toUpperCase(), sha("other"), ` ${IMG_SHA}`]; + for (const claim of claims) { + const s = synapse({ events: { [FILE]: { type: "m.room.message", event_id: FILE, room_id: ROOM, sender: OTHER, content: fileContent({ info: { sha256: claim } }) } } }); + const m = mouthOn(s); + const r = await m.ask(); + assert.equal(r.outcome, "HashMismatch", JSON.stringify(claim)); + assert.equal(m.puts.length, 0); + assert.equal(m.files().length, 0); + assert.equal(s.calls.filter(c => c.startsWith("PUT")).length, 0); + assert.match(m.fetchLines()[0], new RegExp(`sha256=${IMG_SHA} `), "the computed hash, never the claim"); + } +}); + +test("a matching info.sha256 is Stored; in an album thread the receipt is Posted and is the contract's, nothing more", async () => { + const s = synapse(); + const m = mouthOn(s); + const r = await m.ask(); + assert.equal(r.outcome, "Stored"); + assert.equal(r.receipt, "Posted"); + assert.equal(m.files().length, 1); + const [event] = s.receipts(); + assert.equal(event.sender, ME); + assert.deepEqual(event.content, { "m.relates_to": { rel_type: "m.thread", event_id: ROOT }, [docs.fileReceivedType]: { event_id: FILE, sha256: IMG_SHA, size: IMG.length } }); + const text = JSON.stringify(event.content); + assert.ok(!/mxc|\/|https?:|tok-secret|cat\.png|"mind"/.test(text.replace(/"m\.relates_to"|"rel_type"|"m\.thread"|"org\.gamecult\.file_received"/g, "")), "no path, scheme, mxc, token, filename or mind key"); + assert.ok(m.logs.every(l => !l.includes(TOKEN) && !l.includes("mxc://"))); + assert.match(m.fetchLines()[0], /receipt=posted/); + const txn = [...s.sent.keys()][0]; + assert.equal(txn, `fr-${sha("eureka|" + FILE)}`); +}); + +const plainFile = relates => ({ [FILE]: { type: "m.room.message", event_id: FILE, room_id: ROOM, sender: OTHER, content: relates === undefined ? (({ "m.relates_to": _, ...rest }) => rest)(fileContent()) : fileContent({ "m.relates_to": relates }) } }); + +test("a file in no thread gets one plain receipt referencing the file; its reference and content.event_id are one value", async () => { + for (const relates of [undefined, { "m.in_reply_to": { event_id: "$q" } }, { rel_type: "m.reference", event_id: "$z" }]) { + const s = synapse({ events: plainFile(relates) }); + const m = mouthOn(s); + const r = await m.ask({ room: ROOM }); + assert.equal(r.receipt, "Posted"); + assert.equal(s.receipts().length, 1); + const [event] = s.receipts(); + assert.deepEqual(Object.keys(event.content).sort(), ["m.relates_to", docs.fileReceivedType]); + assert.deepEqual(event.content["m.relates_to"], { rel_type: "m.reference", event_id: FILE }); + assert.deepEqual(Object.keys(event.content[docs.fileReceivedType]).sort(), ["event_id", "sha256", "size"]); + assert.equal(event.content["m.relates_to"].event_id, event.content[docs.fileReceivedType].event_id); + const again = await mouthOn(s).ask(); // a restarted mouth reads Matrix, keeps nothing + assert.equal(again.receipt, "Existing"); + assert.equal(s.receipts().length, 1); + } + const root = synapse({ events: plainFile({ rel_type: "m.thread", event_id: FILE }) }); // the file is itself a thread root's reply to itself: still a valid thread shape + assert.equal((await mouthOn(root).ask()).receipt, "Posted"); +}); + +test("idempotence reads the v1 relations path of the thread or of the file, follows next_batch for three pages and never a fourth", async () => { + const s = synapse(); + s.relations.set(`${ROOT}|m.thread`, [[], [], [], [{ type: docs.fileReceivedType, sender: ME, content: { [docs.fileReceivedType]: { event_id: FILE } } }]]); + const m = mouthOn(s); + const r = await m.ask(); + assert.equal(s.gets(/relations/).length, 3, "a fourth page is never asked"); + assert.ok(s.raw.includes(`/_matrix/client/v1/rooms/${encodeURIComponent(ROOM)}/relations/${encodeURIComponent(ROOT)}/m.thread/${docs.fileReceivedType}?dir=b&limit=100`)); + assert.ok(s.raw.includes(`/_matrix/client/v1/rooms/${encodeURIComponent(ROOM)}/relations/${encodeURIComponent(ROOT)}/m.thread/${docs.fileReceivedType}?dir=b&limit=100&from=1`), "page two via next_batch"); + assert.ok(s.calls.every(c => !c.includes("event_type="))); + assert.equal(r.outcome, "Stored"); + assert.equal(r.receipt, null, "not exhausted proves nothing: no post"); + assert.equal(s.receipts().length, 0); + assert.match(m.fetchLines()[0], /receipt=failed/); + const t = synapse(); + t.relations.set(`${ROOT}|m.thread`, [[], [{ type: docs.fileReceivedType, sender: ME, content: { [docs.fileReceivedType]: { event_id: FILE } } }]]); + assert.equal((await mouthOn(t).ask()).receipt, "Existing"); + assert.equal(t.receipts().length, 0); + const f = synapse({ events: plainFile(undefined) }); + await mouthOn(f).ask({ room: ROOM }); + assert.ok(f.raw.some(p => p.startsWith(`/_matrix/client/v1/rooms/${encodeURIComponent(ROOM)}/relations/${encodeURIComponent(FILE)}/m.reference/${docs.fileReceivedType}?dir=b&limit=100`))); +}); + +test("a receipt by another sender, for another file, or redacted does not suppress hers, in both listings", async () => { + const mine = (over = {}) => ({ type: docs.fileReceivedType, sender: ME, content: { [docs.fileReceivedType]: { event_id: FILE } }, ...over }); + const decoys = [mine({ sender: OTHER }), mine({ content: { [docs.fileReceivedType]: { event_id: "$other" } } }), mine({ unsigned: { redacted_because: {} }, content: {} })]; + for (const [events, key, room] of [[undefined, `${ROOT}|m.thread`, ALIAS], [plainFile(undefined), `${FILE}|m.reference`, ROOM]]) { + const s = synapse(events ? { events } : {}); + s.relations.set(key, [decoys]); + const r = await mouthOn(s).ask({ room }); + assert.equal(r.receipt, "Posted"); + assert.equal(s.receipts().length, 1); + } +}); + +test("a failed relations read or a failed post does not fail the fetch: Stored, receipt nil, file kept, log receipt=failed", async () => { + for (const [events, failing, room] of [[undefined, { "/relations/": 500 }, ALIAS], [undefined, { "PUT .*/send/": 500 }, ALIAS], [plainFile(undefined), { "/relations/": 500 }, ROOM], [plainFile(undefined), { "PUT .*/send/": 500 }, ROOM]]) { + const s = synapse({ ...(events ? { events } : {}), failing }); + const m = mouthOn(s); + const r = await m.ask({ room }); + assert.equal(r.outcome, "Stored"); + assert.equal(r.receipt, null); + assert.equal(m.files().length, 1); + assert.match(m.fetchLines()[0], /receipt=failed/); + assert.equal(s.receipts().length, 0); + } +}); + +test("a brake placed after the file is stored posts no receipt: Stored, receipt nil, log receipt=skipped", async () => { + const s = synapse(); + let reads = 0; + const m = mouthOn(s, { brake: async () => { reads++; return !s.mediaHits || reads <= 2 ? true : s.calls.filter(c => c.includes("relations")).length === 0 ? true : false; } }); + const r = await m.ask(); + assert.equal(r.outcome, "Stored"); + assert.equal(r.receipt, null); + assert.equal(m.files().length, 1); + assert.equal(s.calls.filter(c => c.startsWith("PUT")).length, 0); + assert.match(m.fetchLines()[0], /receipt=skipped/); +}); + +test("the fetch is rate limited per Persona: the 31st in an hour is RateLimited with no request; every request counts", async () => { + const s = synapse({ joined: [] }); + let t = 0; + const m = mouthOn(s, { clock: () => t }); + for (let i = 0; i < 30; i++) assert.equal((await m.ask({ requestId: `r${i}` })).outcome, "NotAMember"); + s.calls.length = 0; + assert.equal((await m.ask({ requestId: "r31" })).outcome, "RateLimited"); + noSynapse(s); + t += 3_600_001; + assert.equal((await m.ask({ requestId: "r32" })).outcome, "NotAMember"); +}); + +test("the fetch runs inside the room gate with a send: a fetch queued behind a slow holder whose deadline fires does no Synapse GET and stores nothing", async () => { + const s = synapse(); + const gate = createRoomGate(); + let release; + const holder = gate.run(ROOM, () => new Promise(ok => { release = ok; })); + const m = mouthOn(s, { gate, deadline: 80 }); + const asked = await m.ask({ room: ROOM }); // answers at the deadline, while the holder still holds the gate + assert.equal(asked.outcome, "FetchFailed"); + s.calls.length = 0; + release(); + await holder; + await new Promise(r => setTimeout(r, 30)); + assert.deepEqual(s.calls, [], "the queued fetch ran nothing past its deadline"); + assert.equal(m.puts.length, 0); + assert.equal(m.files().length, 0); + assert.equal(m.fetchLines().length, 1, "one line per fetch"); + assert.match(m.fetchLines()[0], /outcome=FetchFailed .*receipt=skipped/); +}); + +test("a brake placed while a fetch waits in the gate stops it before any request", async () => { + const s = synapse(); + const gate = createRoomGate(); + let release; + const holder = gate.run(ROOM, () => new Promise(ok => { release = ok; })); + let free = true; + const m = mouthOn(s, { gate, brake: async () => free }); + await m.mouth.start(); + s.calls.length = 0; + const asked = m.ask({ room: ROOM }); + await new Promise(r => setTimeout(r, 20)); + free = false; + release(); + await holder; + assert.equal((await asked).outcome, "Brake"); + assert.deepEqual(s.calls, []); + assert.equal(m.puts.length, 0); +}); + +test("one deadline covers the whole fetch: a stall in each read before the put is FetchFailed receipt=skipped, nothing is put or sent once it is released", async () => { + for (const match of [/GET \/joined_rooms/, /GET \/rooms\/[^/]+\/state$/, /GET \/rooms\/[^/]+\/event\//, /media\/download/]) { + const hold = { match }; + const s = synapse(); + const m = mouthOn(s, { deadline: 120 }); + await m.mouth.start(); + s.holds.push(hold); + const r = await m.ask({ room: ROOM }); + assert.equal(r.outcome, "FetchFailed", String(match)); + hold.open?.(); + await new Promise(r => setTimeout(r, 20)); + assert.equal(m.puts.length, 0); + assert.equal(s.calls.filter(c => c.startsWith("PUT")).length, 0); + assert.equal(m.fetchLines().length, 1); + assert.match(m.fetchLines()[0], /outcome=FetchFailed .*receipt=skipped/); + } +}); + +test("a deadline after the put leaves a stored file and no receipt, logged receipt=failed; the retry posts exactly one receipt", async () => { + for (const match of [/GET \/rooms\/[^/]+\/relations\//, /PUT \/rooms\/[^/]+\/send\//]) { + const hold = { match, late: /PUT/.test(match.source) }; + const s = synapse({ holds: [hold] }); + const m = mouthOn(s, { deadline: 150 }); + const r = await m.ask({ room: ROOM }); + assert.equal(r.outcome, "FetchFailed"); + assert.equal(r.image, null); + assert.equal(m.puts.length, 1); + assert.equal(m.files().length, 1); + assert.match(m.fetchLines()[0], /outcome=FetchFailed .*receipt=failed/); + s.holds.length = 0; + const retry = await m.ask({ room: ROOM, requestId: "r2" }); + assert.equal(retry.outcome, "Stored"); + assert.ok(retry.receipt === "Posted" || retry.receipt === "Existing"); + assert.equal(s.receipts().length, 1, "the same txn id: still one receipt"); + } +}); + +test("the mouth log line carries the computed hash, the planted token and mxc are in no line, and a Stored fetch has exactly one line", async () => { + const s = synapse(); + const m = mouthOn(s); + await m.ask(); + assert.equal(m.fetchLines().length, 1); + assert.equal(m.fetchLines()[0], `fetch persona=eureka room=${ROOM} event=${FILE} outcome=Stored size=${IMG.length} type=image/png sha256=${IMG_SHA} receipt=posted`); + assert.ok(m.logs.every(l => !l.includes(TOKEN) && !l.includes("mxc"))); + const j = mouthOn(synapse({ media: { abc: { bytes: jpeg(8, 8) } }, events: { [FILE]: { type: "m.room.message", event_id: FILE, room_id: ROOM, sender: OTHER, content: fileContent({ info: { sha256: IMG_SHA } }) } } })); + assert.equal((await j.ask()).outcome, "HashMismatch"); + assert.match(j.fetchLines()[0], /type=image\/jpeg/); +}); + +test("the receipt document follows its one table", () => { + const image = { path: `${sha(ROOM)}/${IMG_SHA}.png`, mime: "image/png", size: 100, sha256: IMG_SHA }; + for (const receipt of [null, "Posted", "Existing"]) assert.doesNotThrow(() => docs.fetchReceiptTuple({ requestId: "r", outcome: "Stored", roomId: ROOM, image, receipt })); + assert.doesNotThrow(() => docs.fetchReceiptTuple({ requestId: "r", outcome: "StoredUnverified", roomId: ROOM, image })); + for (const receipt of ["Posted", "Existing", "$ev", "posted"]) assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome: "StoredUnverified", roomId: ROOM, image, receipt })); + assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome: "Stored", roomId: ROOM, image, receipt: "$ev" })); + assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome: "Stored" }), /exactly for Stored/); + for (const outcome of docs.fetchOutcomes.filter(w => !w.startsWith("Stored"))) { + assert.doesNotThrow(() => docs.fetchReceiptTuple({ requestId: "r", outcome })); + assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome, roomId: ROOM })); + assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome, image })); + assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome, receipt: "Posted" })); + } + assert.equal(docs.fetchOutcomes.length, 14); + assert.throws(() => docs.fetchReceiptTuple({ requestId: "r", outcome: "Nope" }), /unknown fetch outcome/); + const tuple = docs.fetchReceiptTuple({ requestId: "r", outcome: "Stored", roomId: ROOM, image, receipt: "Posted" }); + assert.equal(tuple.length, 5); + assert.deepEqual(docs.parseFetchReceipt(tuple), { requestId: "r", outcome: "Stored", roomId: ROOM, image, receipt: "Posted" }); + assert.throws(() => docs.parseFetchReceipt([...tuple, "extra"]), /5-field/); + assert.throws(() => docs.parseFetchReceipt(tuple.slice(0, 4)), /5-field/); + assert.throws(() => docs.parseFetchReceipt(["r", "StoredUnverified", ROOM, tuple[3], "Posted"])); + assert.throws(() => docs.parseFetchRequest(["r", "eureka", "x".repeat(300), FILE]), /over 255/); +}); + +test("the flag is exactly one", () => { + assert.equal(fetchFromEnv({}), false); + for (const v of ["0", "true", "yes", "", " 1", "1 ", "01"]) assert.equal(fetchFromEnv({ CHAT_FETCH: v }), false, JSON.stringify(v)); + assert.equal(fetchFromEnv({ CHAT_FETCH: "1" }), true); +}); + +test("images.put has exactly two call sites in the mouth and the fetch's comes after the hash decision; no second media fetcher", () => { + const source = readFileSync(resolve(import.meta.dirname, "..", "tools", "persona-matrix-mouth.mjs"), "utf8"); + assert.equal(source.match(/images\.put\(/g).length, 2); + const fetchBody = source.slice(source.indexOf("async function fetchAttachment"), source.indexOf("async function postFileReceipt")); + assert.ok(fetchBody.indexOf("HashMismatch") < fetchBody.indexOf("images.put("), "the hash decision precedes the put"); + assert.ok(fetchBody.indexOf("throwIfAborted", fetchBody.indexOf("HashMismatch")) < fetchBody.indexOf("images.put("), "the deadline is checked right before the put"); + assert.ok(!/media\/download/.test(source), "no media GET outside fetchImage"); + assert.equal([...source.matchAll(/\/state\/(m\.|uk\.)|event_type=/g)].length, 0); +}); + +test("the receipt names the event the GET resolved: reference and content.event_id both carry the returned id, in a thread file and a plain one", async () => { + const answered = { type: "m.room.message", event_id: "$resolved", room_id: ROOM, sender: OTHER }; + const plain = synapse({ events: { [FILE]: { ...answered, content: plainFile(undefined)[FILE].content } } }); + assert.equal((await mouthOn(plain).ask({ room: ROOM })).receipt, "Posted"); + const [event] = plain.receipts(); + assert.equal(event.content["m.relates_to"].event_id, "$resolved"); + assert.equal(event.content[docs.fileReceivedType].event_id, "$resolved"); + const threaded = synapse({ events: { [FILE]: { ...answered, content: fileContent() } } }); + await mouthOn(threaded).ask({ room: ROOM }); + assert.equal(threaded.receipts()[0].content[docs.fileReceivedType].event_id, "$resolved"); + const bad = synapse({ events: { [FILE]: { ...answered, event_id: "$a/../b", content: fileContent() } } }); + assert.equal((await mouthOn(bad).ask({ room: ROOM })).outcome, "NoAttachment"); + assert.equal(bad.receipts().length, 0); +}); + +test("a failure leaves the log as fixed words: a transport error holding a URL, an mxc and a sender-chosen 200-byte album root reaches no line", async () => { + const root = `$${"r".repeat(199)}`; + const taint = `http://hs.test/_matrix/client/v3/joined_rooms mxc://gamecult.org/abc ${root} Bearer ${TOKEN}`; + for (const [pattern, expectFetch] of [[/GET \/rooms\/[^/]+\/relations\//, "Stored"], [/GET \/rooms\/[^/]+\/state$/, "FetchFailed"], [/PUT \/rooms\/[^/]+\/send\//, "Stored"]]) { + const s = synapse({ events: { [FILE]: { type: "m.room.message", event_id: FILE, room_id: ROOM, sender: OTHER, content: fileContent({ "m.relates_to": { rel_type: "m.thread", event_id: root } }) } } }); + const real = s.fetch; + s.fetch = async (url, init = {}) => { + if (pattern.test(`${init.method ?? "GET"} ${decodeURIComponent(new URL(url).pathname).replace(/^\/_matrix\/client\/v[13]/, "")}`)) throw new Error(`fetch failed ${taint}`); + return real(url, init); + }; + const m = mouthOn(s); + assert.equal((await m.ask({ room: ROOM })).outcome, expectFetch, String(pattern)); + assert.equal(m.fetchLines().length, 1, "still one fetch line"); + for (const line of m.logs) { + for (const bit of ["http", "mxc", root, "r".repeat(40), TOKEN, "unreachable"]) assert.ok(!line.includes(bit), `${bit} in: ${line.slice(0, 120)}`); + } + assert.match(m.fetchLines()[0], / cause=Error\/-$/); + } + const status = synapse(); + status.failing = { "/relations/": 500 }; + const m = mouthOn(status); + await m.ask(); + assert.match(m.fetchLines()[0], /receipt=failed cause=Error\/500$/); +}); + +test("the deadline aborts the in-flight media GET: the room gate is free again at once, not after the media timeout", async () => { + const s = synapse({ holds: [{ match: /media\/download/ }] }); + const gate = createRoomGate(); + const m = mouthOn(s, { gate, deadline: 100 }); + assert.equal((await m.ask({ room: ROOM })).outcome, "FetchFailed"); + const freed = await Promise.race([gate.run(ROOM, async () => "free"), new Promise(r => setTimeout(() => r("held"), 300))]); + assert.equal(freed, "free"); + assert.equal(m.puts.length, 0); +}); + +test("a redacted receipt does not count as hers even when its content is intact", async () => { + const s = synapse(); + s.relations.set(`${ROOT}|m.thread`, [[{ type: docs.fileReceivedType, sender: ME, unsigned: { redacted_because: {} }, content: { [docs.fileReceivedType]: { event_id: FILE } } }]]); + assert.equal((await mouthOn(s).ask({ room: ROOM })).receipt, "Posted"); + assert.equal(s.receipts().length, 1); +}); + +test("a deadline that passes while the brake is re-read before the receipt PUT sends nothing", async () => { + const s = synapse(); + let reads = 0, free; + const m = mouthOn(s, { deadline: 150, brake: async () => (++reads === 3 ? new Promise(ok => { free = () => ok(true); }) : true) }); + assert.equal((await m.ask({ room: ROOM })).outcome, "FetchFailed"); + free(); + await new Promise(r => setTimeout(r, 30)); + assert.equal(s.calls.filter(c => c.startsWith("PUT")).length, 0); + assert.equal(m.puts.length, 1); +}); + +test("a store that throws is one fetch line with a fixed cause: its code, message and name text reach no line", async () => { + const taint = `/srv/images/secret-path http://hs.test/x mxc://gamecult.org/abc ${TOKEN}`; + const s = synapse(); + const m = mouthOn(s, { put: () => { throw Object.assign(new Error(taint), { code: taint, name: "not a class name" }); } }); + assert.equal((await m.ask()).outcome, "StoreFailed"); + assert.equal(m.fetchLines().length, 1); + assert.match(m.fetchLines()[0], /outcome=StoreFailed .* cause=Error\/-$/); + assert.equal(m.logs.length, m.fetchLines().length, "no other line"); + for (const line of m.logs) for (const bit of ["secret-path", "http", "mxc", TOKEN, "EACCES"]) assert.ok(!line.includes(bit), `${bit} in: ${line}`); + assert.equal(s.calls.filter(c => c.startsWith("PUT")).length, 0, "no receipt for an unstored file"); +}); + +test("an event id with characters encodeURIComponent changes goes out as one encoded path segment in every request built from it", async () => { + const hostile = ["$a+b=c", "$a@b:c", "$a&b", "$a b".replace(" ", "!"), "$a'(b)*", "$a;b,c", "$a$b"]; + for (const [id, threaded] of hostile.flatMap(h => [[h, true], [h, false]])) { + assert.ok(docs.isFetchId("$", id), id); + assert.notEqual(encodeURIComponent(id), id, `${id} is changed by encoding`); + const s = synapse({ events: { [id]: { type: "m.room.message", event_id: id, room_id: ROOM, sender: OTHER, content: fileContent({ "m.relates_to": threaded ? { rel_type: "m.thread", event_id: id } : undefined }) } } }); + const m = mouthOn(s); + assert.equal((await m.ask({ eventId: id, room: ROOM })).outcome, "Stored", id); + const enc = encodeURIComponent(id); + const eventGet = s.raw.filter(p => p.includes("/event/")); + const relGet = s.raw.filter(p => p.includes("/relations/")); + assert.ok(eventGet.length === 1 && eventGet[0] === `/_matrix/client/v3/rooms/${encodeURIComponent(ROOM)}/event/${enc}`, `event GET: ${eventGet}`); + assert.ok(relGet.length >= 1 && relGet.every(p => p.startsWith(`/_matrix/client/v1/rooms/${encodeURIComponent(ROOM)}/relations/${enc}/${threaded ? "m.thread" : "m.reference"}/`)), `relations GET: ${relGet}`); + assert.ok(s.raw.every(p => !p.split("?")[0].includes(id) || id === enc), "no raw id in a request path"); + } +}); diff --git a/tests/persona-matrix-mouth-follow.test.mjs b/tests/persona-matrix-mouth-follow.test.mjs new file mode 100644 index 0000000..c1a705d --- /dev/null +++ b/tests/persona-matrix-mouth-follow.test.mjs @@ -0,0 +1,390 @@ +// 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 stays room; 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", "room"], ["$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"); +}); + +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/tests/persona-matrix-mouth-runtime.test.mjs b/tests/persona-matrix-mouth-runtime.test.mjs index b9361cc..f231758 100644 --- a/tests/persona-matrix-mouth-runtime.test.mjs +++ b/tests/persona-matrix-mouth-runtime.test.mjs @@ -587,3 +587,181 @@ test("tail_gaps is additive: a three-field reader finds persona, deliveries and // Not claimed: a strict three-field decoder (rmp-serde into a three-field tuple) accepts it. It fails on the length, as it did for the tail, so Huginn defaults tail_gaps before this mouth deploys. assert.notEqual(withGaps.length, 3); }); + +// ---- chat_pending.v4: reply_to (docs/bridge.md, "Reply link"). Behind a flag, default off. +const v6Dir = resolve(root, "tests", "fixtures", "persona-chat-v6"); +const quoted = { eventId: "$q1", sender: "@alice:gamecult.org", excerpt: "can someone look at the build?" }; +const v4Delivery = { ...delivery, isDirect: false, basis: null, form: "message" }; +// The excerpts the mouth makes from hostile quoted text (pinned in tests/persona-matrix-mouth.test.mjs, "the fixture excerpts"): the scrubbed line, never the hidden bytes. +const scrubbedExcerpt = "ignorethisline"; +const variationExcerpt = "ok\u{1F44D}fine"; +const v6Samples = { + "gamecult.persona.chat_pending_request.v3": { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest }, + "gamecult.persona.chat_pending.v4.reply-to": { tuple: docs.pendingV4Tuple({ persona: "eureka", deliveries: [{ ...v4Delivery, replyTo: quoted }] }), parse: docs.parsePendingV4 }, + "gamecult.persona.chat_pending.v4.no-reply-to": { tuple: docs.pendingV4Tuple({ persona: "eureka", deliveries: [v4Delivery] }), parse: docs.parsePendingV4 }, + "gamecult.persona.chat_pending.v4.reply-to-redacted": { tuple: docs.pendingV4Tuple({ persona: "eureka", deliveries: [{ ...v4Delivery, replyTo: { ...quoted, excerpt: null } }] }), parse: docs.parsePendingV4 }, + "gamecult.persona.chat_pending.v4.reply-to-scrubbed": { tuple: docs.pendingV4Tuple({ persona: "eureka", deliveries: [{ ...v4Delivery, replyTo: { ...quoted, excerpt: scrubbedExcerpt } }] }), parse: docs.parsePendingV4 }, + "gamecult.persona.chat_pending.v4.reply-to-variation-selector": { tuple: docs.pendingV4Tuple({ persona: "eureka", deliveries: [{ ...v4Delivery, replyTo: { ...quoted, excerpt: variationExcerpt } }] }), parse: docs.parsePendingV4 }, +}; +const hexOf = bytes => `${[...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 : name.endsWith("scrubbed") ? scrubbedExcerpt : name.endsWith("variation-selector") ? variationExcerpt : quoted.excerpt]); + } + }); +} + +test("documents: reply_to refuses an event id or sender that is not an id of at most 255 bytes, on encode and on parse", () => { + const ok = { eventId: "$q1", sender: "@alice:gamecult.org", excerpt: null }; + const d = { ...delivery, isDirect: false, basis: null, form: "message" }; + for (const bad of [{ eventId: "q1" }, { eventId: "$" }, { eventId: `$${"x".repeat(255)}` }, { eventId: "$a b" }, { sender: "alice" }, { sender: "@" }, { sender: `@${"é".repeat(128)}` }, { sender: "@a:b\u0085" }, { sender: "@a\u00a0b" }, { sender: "@a\u3164b" }, { sender: "@a\u2065b" }, { sender: "@a\u200db" }, { sender: "@a\ue000b" }, { sender: "@a\u007fb" }, { sender: "@a\u200bb" }, { sender: "@a\u202eb" }, { sender: "@a\ud800b" }, { eventId: "$a\u3000b" }]) + assert.throws(() => docs.deliveryV4Tuple({ ...d, replyTo: { ...ok, ...bad } }), /reply_to/, JSON.stringify(bad).slice(0, 40)); + assert.doesNotThrow(() => docs.deliveryV4Tuple({ ...d, replyTo: { ...ok, eventId: `$${"x".repeat(254)}` } })); + const tuple = docs.deliveryV4Tuple({ ...d, replyTo: ok }); + tuple[14] = ["q1", ok.sender, null]; + assert.throws(() => docs.parseDeliveryV4(tuple), /reply_to/); +}); + +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, fetchEnabled, mouth: given }) { + const mouth = given ?? { 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 }), ...(fetchEnabled === undefined ? {} : { fetchEnabled }) }); + 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); +}); + +// ---- chat_fetch (docs/bridge.md, "Fetch a named file"): the request and receipt kinds, behind CHAT_FETCH, default off. +const fetchTuple = docs.fetchRequestTuple({ requestId: "req-f1", persona: "eureka", room: "#swarm:gamecult.org", eventId: "$file1" }); +const fetchStub = () => { const asked = []; return { asked, mouth: { fetchAttachment: async request => { asked.push(request); return { requestId: request.requestId, outcome: "NoAttachment", roomId: null, image: null, receipt: null }; } } }; }; + +test("fetch flag off (unset, false): a fetch request is answered exactly as any unknown kind is, byte for byte, with no mouth call and the existing unknown-kind log line only", async () => { + for (const fetchEnabled of [undefined, false]) { + const { asked, mouth } = fetchStub(); + const off = await askOnce({ fetchEnabled, kind: docs.kinds.fetchRequest, tuple: fetchTuple, mouth }); + assert.deepEqual([off.document.schemaId, docs.parseRefusal(msgpack.decode(off.document.payload))], [docs.kinds.refusal, { persona: "eureka", refusal: "UnknownKind" }]); + assert.equal(asked.length, 0, "the mouth was never asked"); + assert.deepEqual(off.logs, [`chat endpoint refused a request of an unknown kind: ${docs.kinds.fetchRequest}`]); + const made = await askOnce({ fetchEnabled, kind: "gamecult.persona.chat_fetch_request.v9", tuple: fetchTuple, mouth }); + assert.equal(made.document.schemaId, off.document.schemaId); + assert.equal(made.document.recordKey, off.document.recordKey); + assert.deepEqual(Buffer.from(made.document.payload), Buffer.from(off.document.payload), "byte for byte the answer to a kind that never existed"); + } +}); + +test("fetch flag on: the request reaches the Persona's mouth and the receipt comes back typed, 5 slots; another Persona's request is UnknownPersona", async () => { + const { asked, mouth } = fetchStub(); + const on = await askOnce({ fetchEnabled: true, kind: docs.kinds.fetchRequest, tuple: fetchTuple, mouth }); + assert.equal(on.document.schemaId, docs.kinds.fetchReceipt); + assert.deepEqual(asked, [{ requestId: "req-f1", persona: "eureka", room: "#swarm:gamecult.org", eventId: "$file1" }]); + assert.deepEqual(docs.parseFetchReceipt(msgpack.decode(on.document.payload)), { requestId: "req-f1", outcome: "NoAttachment", roomId: null, image: null, receipt: null }); + const ghost = await askOnce({ fetchEnabled: true, kind: docs.kinds.fetchRequest, tuple: docs.fetchRequestTuple({ requestId: "r", persona: "ghost", room: "#a:b", eventId: "$e" }), mouth }); + assert.equal(docs.parseFetchReceipt(msgpack.decode(ghost.document.payload)).outcome, "UnknownPersona"); + assert.equal(asked.length, 1); +}); + +// ---- persona-chat-v7: the fetch request and its receipts. Huginn copies the msgpack byte for byte (its persona-chat-v9). +const v7Dir = resolve(root, "tests", "fixtures", "persona-chat-v7"); +const v7Image = { path: `${"cd".repeat(32)}/${"ab".repeat(32)}.png`, mime: "image/png", size: 2048, sha256: "ab".repeat(32) }; +const v7Receipt = receipt => ({ tuple: docs.fetchReceiptTuple({ requestId: "req-f1", outcome: "Stored", roomId: "!abc:gamecult.org", image: v7Image, receipt }), parse: docs.parseFetchReceipt }); +const v7Samples = { + "gamecult.persona.chat_fetch_request.v1": { tuple: fetchTuple, parse: docs.parseFetchRequest }, + "gamecult.persona.chat_fetch_receipt.v1.stored-nil": v7Receipt(null), + "gamecult.persona.chat_fetch_receipt.v1.stored-posted": v7Receipt("Posted"), + "gamecult.persona.chat_fetch_receipt.v1.stored-existing": v7Receipt("Existing"), + "gamecult.persona.chat_fetch_receipt.v1.stored-unverified": { tuple: docs.fetchReceiptTuple({ requestId: "req-f1", outcome: "StoredUnverified", roomId: "!abc:gamecult.org", image: v7Image }), parse: docs.parseFetchReceipt }, + "gamecult.persona.chat_fetch_receipt.v1.refused": { tuple: docs.fetchReceiptTuple({ requestId: "req-f1", outcome: "BridgedRoom" }), parse: docs.parseFetchReceipt }, +}; +for (const [name, { tuple, parse }] of Object.entries(v7Samples)) { + test(`golden fixture: ${name} is byte-stable (msgpack and hex) and parses back, and re-encodes byte-equal`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v7Dir, { recursive: true }); await writeFile(resolve(v7Dir, `${name}.msgpack`), bytes); await writeFile(resolve(v7Dir, `${name}.hex`), hexOf(bytes)); } + assert.deepEqual(bytes, await readFile(resolve(v7Dir, `${name}.msgpack`)), "wire bytes changed: Huginn decodes these fixtures"); + assert.equal(await readFile(resolve(v7Dir, `${name}.hex`), "utf8"), hexOf(bytes), "the hex dump is the msgpack file"); + const decoded = msgpack.decode(bytes); + assert.deepEqual(parse(decoded), parse(tuple)); + assert.equal(decoded.length, name.includes("request") ? 4 : 5); + if (!name.includes("request")) assert.deepEqual(Buffer.from(msgpack.encode(docs.fetchReceiptTuple(parse(decoded)))), bytes, "decode then re-encode is byte-equal"); + }); +} diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs index b2ed814..958b684 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,412 @@ 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); +}); + +// ---- 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's basis and reply_to sender, not its excerpt, 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: null }); + assert.equal(r.again.replyTo.excerpt, null, "the renewed entry does not serve the old excerpt"); +}); + +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 wait for the next round, none is delivered without its reply_to or lost", 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(); + const next = await mouth.syncOnce(); + return { made: [made, next], hs }; + })(); + try { + assert.deepEqual(made.map(m => m.length), [8, 2]); + assert.deepEqual(made.flat().map(d => d.eventId), ids.map((_, i) => `$q${i}`)); + assert.ok(made.flat().every(d => d.replyTo !== null)); + assert.equal(ids.filter(t => eventGets(hs, t) > 0).length, 10); + } 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?.sender, r.later.replyTo?.excerpt], ["reply", SELF, null], 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's sender for 10 minutes from the last good read and its excerpt for 60 s, then drop the hit; 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: sender kept, excerpt gone + assert.equal((await step(0)).replyTo?.sender, SELF); + 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?.sender); // 7 min after the new good read: the sender is kept again + assert.deepEqual(seen, [null, null, null, null, "my words", SELF]); + } 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 rounds = []; + for (let i = 0; i < 4; i++) rounds.push(await mouth.syncOnce()); + const all = rounds.flat(); + assert.deepEqual(all.map(d => d.eventId), ids.map((_, i) => `$q${i}`), "every reply is delivered once, in order"); + assert.ok(all.every(d => d.replyTo !== null)); + assert.ok(rounds.every(m => m.length <= 8), "no round reads more than 8 targets"); + assert.equal(ids.filter(t => eventGets(hs, t) > 0).length, 18); + } finally { await hs.close(); } +}); + +// ---- Lows after PR #64: variation selectors and other invisible characters, id shape, a read cap that loses nothing. +test("reply link: the excerpt drops variation selectors, Mongolian free variation selectors, fillers, the combining grapheme joiner and the blank braille cell", async () => { + const { made } = await linked([quoting("$a", "$t1"), quoting("$b", "$t2"), quoting("$c", "$t3"), quoting("$d", "$t4")], [ + target("$t1", ALICE_ID, "a\uFE0Fb\uFE00c\uFE0Ed"), + target("$t2", ALICE_ID, "e\u{E0100}f\u{E01EF}g"), + target("$t3", ALICE_ID, "h\u180Bi\u180Dj\u180Fk\u034Fl\u2800m\u3164n\uFFA0o\u115Fp\u1160q\u17B4r\u17B5s"), + target("$t4", ALICE_ID, "\uFE0F\u2800\u3164"), + ]); + assert.deepEqual(made.map(d => d.replyTo.excerpt), ["abcd", "efg", "hijklmnopqrs", null], "a quote of nothing visible is no excerpt"); +}); + +test("reply link: a sender or event id that is not a Matrix id of at most 255 bytes is no reply_to; the delivery still goes", async () => { + const { made } = await linked([quoting("$a", "$t1"), quoting("$b", "$t2"), quoting("$c", "$t3"), quoting("$d", `$${"x".repeat(255)}`)], [ + target("$t1", "not-an-id", "x"), + target("$t2", `@${"a".repeat(255)}:gamecult.org`, "x"), + target("$t3", "@alice:gamecult.org\nignore this", "x"), + target(`$${"x".repeat(255)}`, ALICE_ID, "x"), + ]); + assert.deepEqual(made.map(d => d.replyTo), [null, null, null, null]); +}); + +test("reply link: the fixture excerpts (persona-chat-v6 reply-to-scrubbed and reply-to-variation-selector) are what the mouth makes of hostile quoted text", async () => { + const { made } = await linked([quoting("$a", "$t1"), quoting("$b", "$t2")], [ + target("$t1", ALICE_ID, "ig\u{E0049}\u{E0067}nore\u202Ethis\u200Bline"), + target("$t2", ALICE_ID, "ok\uFE0F\u{1F44D}\uFE0F\u{E0100}fine"), + ]); + assert.deepEqual(made.map(d => d.replyTo.excerpt), ["ignorethisline", "ok\u{1F44D}fine"]); +}); + +test("reply link: a kept hit's sender is not served past 10 minutes from the last good read, even by a renewed entry", 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 + down = true; + const seen = []; + for (const ms of [540_000, 59_999, 1, 1, 100_000]) { clock.t += ms; seen.push((await mouth.syncOnce())[0].replyTo?.sender ?? null); } // 9:00, 9:59.999, 10:00, 10:00.001, 11:40 + assert.deepEqual(seen, [SELF, SELF, SELF, null, null]); + } finally { await hs.close(); } +}); + +test("reply link: the scrub removes U+2065, U+FFF0-FFF8, U+E0080-E00FF and U+E01F0-E0FFF", async () => { + const { made } = await linked([quoting("$a", "$t1"), quoting("$b", "$t2"), quoting("$c", "$t3"), quoting("$d", "$t4")], [ + target("$t1", ALICE_ID, "a\u2065b"), + target("$t2", ALICE_ID, "c\uFFF0d\uFFF8e"), + target("$t3", ALICE_ID, "f\u{E0080}g\u{E00FF}h"), + target("$t4", ALICE_ID, "i\u{E01F0}j\u{E0FFF}k"), + ]); + assert.deepEqual(made.map(d => d.replyTo.excerpt), ["ab", "cde", "fgh", "ijk"]); +}); + +// ---- Lows after PR #68: id shape on every delivery field, mentions bounded in bytes, a read cap on all target GETs. +const ID_OK = { seq: 1, roomAlias: "#swarm:gamecult.test", roomId: ROOM, eventId: "$e", sender: ALICE_ID, senderDisplay: "alice", text: "hi", atUnixMs: 1, context: [{ sender: ALICE_ID, text: "x", atUnixMs: 0 }], mentions: [SELF], isDirect: false, image: null }; + +test("round cap: 12 plain replies to 12 distinct targets make at most 8 /event/ GETs a round (basis reads count), each delivered once, in order, with reply_to", async () => { + const ids = Array.from({ length: 12 }, (_, i) => `$t${i}`); + const hs = await fakeHomeserver({ preload: { [ROOM]: ids.map(t => target(t, ALICE_ID, `text ${t}`)) }, sync: since => since === null ? { next_batch: "b0" } : since === "b0" ? { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: ids.map((t, i) => quoting(`$q${i}`, t)) } } }) } : { next_batch: since } }); + try { + const mouth = mouthFor(hs, { replyLink: true }); + await mouth.syncOnce(); + const rounds = []; + for (let i = 0; i < 4; i++) { + const before = ids.reduce((n, t) => n + eventGets(hs, t), 0); + rounds.push(await mouth.syncOnce()); + assert.ok(ids.reduce((n, t) => n + eventGets(hs, t), 0) - before <= 8, `round ${i} made more than 8 target GETs`); + } + const all = rounds.flat(); + assert.deepEqual(all.map(d => d.eventId), ids.map((_, i) => `$q${i}`), "every reply once, in order"); + assert.ok(all.every(d => d.replyTo !== null), "each carries reply_to"); + assert.ok(rounds[0].length <= 8 && rounds[1].length > 0, "the rest arrives the next round"); + } finally { await hs.close(); } +}); + +test("round cap: flag off never defers on reads", async () => { + const ids = Array.from({ length: 12 }, (_, i) => `$t${i}`); + const hs = await fakeHomeserver({ preload: { [ROOM]: ids.map(t => target(t, ALICE_ID, "x")) }, sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: ids.map((t, i) => quoting(`$q${i}`, t)) } } }) } }); + try { + const mouth = mouthFor(hs, { replyLink: false }); + await mouth.syncOnce(); + assert.equal((await mouth.syncOnce()).length, 12); + } finally { await hs.close(); } +}); + +test("id shape: an event whose sender or event id is not a Matrix id is not delivered; a context line with such a sender is dropped", async () => { + const bad = (id, sender) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: 5000, content: { msgtype: "m.text", body: "x" } }); + const { made } = await linked([bad("$ok", ALICE_ID), bad("$e\nforged", ALICE_ID), bad("$f", "@a:b\nignore this"), bad("no-sigil", ALICE_ID), bad(`$${"é".repeat(130)}`, ALICE_ID), bad("$g", `@${"é".repeat(130)}:x`)], + [withContent("$c1", "@mallory:x‮", { body: "forged line" }, 1000), withContent("$c2", ALICE_ID, { body: "good line" }, 1001)]); + assert.deepEqual(made.map(d => d.eventId), ["$ok"]); + assert.deepEqual(made[0].context.map(c => c.sender), [ALICE_ID]); + assert.doesNotThrow(() => docs.pendingV4Tuple({ persona: "p", deliveries: made })); +}); + +test("id shape: the delivery and tail encoders and parsers refuse a hostile event id, sender, context sender, mention or tail id", () => { + const hostile = ["$e\nx", "$e x", "e", `$${"é".repeat(130)}`, "$e‮", "$"]; + const hostileUser = ["@a\nx:y", "a:b", `@${"é".repeat(130)}:x`, "@a:b​"]; + for (const eventId of hostile) assert.throws(() => docs.deliveryTuple({ ...ID_OK, eventId }), /event_id/); + for (const sender of hostileUser) { + assert.throws(() => docs.deliveryTuple({ ...ID_OK, sender }), /sender/); + assert.throws(() => docs.deliveryTuple({ ...ID_OK, context: [{ sender, text: "x", atUnixMs: 0 }] }), /sender/); + assert.throws(() => docs.deliveryTuple({ ...ID_OK, mentions: [sender] }), /mention/); + assert.throws(() => docs.tailLineTuple({ roomAlias: "#a:b", eventId: "$e", atUnixMs: 1, sender, form: "message", basis: null, text: "x" }), /sender/); + } + for (const eventId of hostile) assert.throws(() => docs.tailLineTuple({ roomAlias: "#a:b", eventId, atUnixMs: 1, sender: ALICE_ID, form: "message", basis: null, text: "x" }), /event_id/); + const good = docs.deliveryV4Tuple({ ...ID_OK, basis: null, form: "message", replyTo: null }); + assert.doesNotThrow(() => docs.parseDeliveryV4(good)); + const parsed = (i, v) => { const t = structuredClone(good); if (i === 8) t[8][0][0] = v; else t[i] = v; return t; }; + assert.throws(() => docs.parseDeliveryV4(parsed(3, "$e\nx")), /event_id/); + assert.throws(() => docs.parseDeliveryV4(parsed(4, "@a\nb:c")), /sender/); + assert.throws(() => docs.parseDeliveryV4(parsed(8, "@a\nb:c")), /sender/); + const pending = docs.pendingV3Tuple({ persona: "p", deliveries: [], tail: [{ roomAlias: "#a:b", eventId: "$e", atUnixMs: 1, sender: ALICE_ID, form: "message", basis: null, text: "x" }] }); + assert.doesNotThrow(() => docs.parsePendingV3(pending)); + assert.throws(() => docs.parsePendingV3(JSON.parse(JSON.stringify(pending).replace('"$e"', '"$e\\nx"'))), /event_id/); + assert.throws(() => docs.parsePendingV3(JSON.parse(JSON.stringify(pending).replace(`"${ALICE_ID}"`, '"@a\\nb:c"'))), /sender/); +}); + +test("id shape: a mention longer than 255 bytes, though under 255 characters, is dropped; the delivery goes", async () => { + const { made } = await linked([withContent("$m", META, { body: "hi", "m.mentions": { user_ids: [SELF, `@${"é".repeat(200)}:x`, "@a b:x"] } })], []); + assert.deepEqual(made.map(d => d.mentions), [[SELF]]); +}); + +const threadOf = (id, root, to) => withContent(id, META, { body: "re", "m.relates_to": { rel_type: "m.thread", event_id: root, "m.in_reply_to": { event_id: to } } }); +async function capRun(events, { clock = { t: 1_000_000 }, lapseOnR = 0, steps = 3 } = {}) { + const ids = [...Array.from({ length: 8 }, (_, i) => `$y${i}`), "$x", "$r"]; + const hs = await fakeHomeserver({ preload: { [ROOM]: ids.map(t => target(t, ALICE_ID, `text ${t}`)) }, sync: since => since === null ? { next_batch: "b0" } : since === "b0" ? { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [quoting("$qx", "$x")] } } }) } : since === "b1" ? { next_batch: "b2", ...joined({ [ROOM]: { timeline: { events } } }) } : { next_batch: since } }); + try { + const fetch = (u, o) => { if (/\/event\/%24r(\?|$)/.test(String(u))) clock.t += lapseOnR; return hs.fetch(u, o); }; + const mouth = mouthFor(hs, { replyLink: true, now: () => clock.t, fetch }); + await mouth.syncOnce(); + await mouth.syncOnce(); // $x read at t0 + clock.t += 59_000; + const rounds = []; + for (let i = 0; i < steps; i++) rounds.push(await mouth.syncOnce()); + return { rounds, hs, gets: id => eventGets(hs, id) }; + } finally { await hs.close(); } +} + +test("round cap: a thread root counts toward the 8 reads: 8 plain replies then a threaded one defers the threaded one to the next round", async () => { + const { rounds } = await capRun([...Array.from({ length: 8 }, (_, i) => quoting(`$q${i}`, `$y${i}`)), threadOf("$th", "$r", "$x")]); + assert.deepEqual(rounds[0].map(d => d.eventId), Array.from({ length: 8 }, (_, i) => `$q${i}`)); + assert.deepEqual(rounds[1].map(d => d.eventId), ["$th"]); +}); + +test("round cap: a target cached at the check is served from the round even when its 60 s keep lapses between the check and the read", async () => { + const { rounds, gets } = await capRun([...Array.from({ length: 6 }, (_, i) => quoting(`$q${i}`, `$y${i}`)), threadOf("$th", "$x", "$r")], { lapseOnR: 2000 }); + assert.equal(rounds.flat().length, 7, "the six replies and the threaded one: 6 + 1 counted read"); + assert.equal(gets("$x"), 2, "$x: the read at t0 and the thread context's own root read, none for the lapsed keep"); + assert.ok(rounds[0].every(d => d.replyTo !== null)); +}); + +test("display name: a sender with an empty localpart is delivered under its own id, and the page encodes", async () => { + const { rounds } = await capRun([withContent("$odd", "@:evil.test", { body: "hi" })], { steps: 1 }); + assert.equal(rounds[0][0].senderDisplay, "@:evil.test"); + assert.doesNotThrow(() => docs.pendingV4Tuple({ persona: "p", deliveries: rounds[0] })); +}); + +test("round cap: a pin serves only the event whose check made it: a later event's target past its 60 s keep is read again, as counted", async () => { + const { rounds, gets } = await capRun([quoting("$e1", "$x"), quoting("$e2", "$r"), quoting("$e3", "$x")], { lapseOnR: 2000 }); + assert.deepEqual(rounds.flat().map(d => d.eventId), ["$e1", "$e2", "$e3"]); + assert.equal(gets("$x"), 2, "the read at t0 and one re-read for $e3"); +}); 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/merge-saga-mutants.py b/tools/merge-saga-mutants.py new file mode 100644 index 0000000..1b8da0b --- /dev/null +++ b/tools/merge-saga-mutants.py @@ -0,0 +1,83 @@ +"""One-off mutation probe for the account merge saga (not a gate; survivors are triaged by name). +Usage, from any directory with BIFROST_TEST_PG and the dotnet environment set: + python3 tools/merge-saga-mutants.py [mutant-name ...] # no names: all +Each mutant is one exact text edit, applied, run against the merge tests (and again alone if it fails), then restored. +Logs go to $MUTANT_LOGS (default ./mutant-logs). Expected: every mutant KILLED. +""" +import subprocess, re, sys, os, json +WT=subprocess.run(['git','rev-parse','--show-toplevel'],capture_output=True,text=True,check=True,cwd=os.path.dirname(os.path.abspath(__file__))).stdout.strip() +LOGS=os.environ.get('MUTANT_LOGS','./mutant-logs'); os.makedirs(LOGS,exist_ok=True) +SAGA='src/Bifrost.Web/Features/Membership/AccountMergeSaga.cs' +LEDGER='src/Bifrost.Web/Features/Membership/AccountMergeLedger.cs' +FILTER='FullyQualifiedName~AccountMergeSaga|FullyQualifiedName~AccountMergePostgres' +M=[ +('derive-move-id', SAGA, [(" var merge = new AccountMerge\n {\n State = AccountMergeState.Confirmed,"," var fixedId = Guid.NewGuid();\n var merge = new AccountMerge\n {\n Id = fixedId,\n State = AccountMergeState.Confirmed,"),('HeimdallMoveId = Guid.NewGuid().ToString("N"),','HeimdallMoveId = fixedId.ToString("N"),')], None), +('no-wipe-on-moved', SAGA, " merge.SealedProofs = null;\n if (moved.Receipt.AccountId != destination)", " if (moved.Receipt.AccountId != destination)"), +('no-wipe-on-refused', SAGA, " // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was.\n merge.SealedProofs = null;", " // Heimdall applied nothing before a 4xx. A forward refusal ends the merge; a refused undo leaves it as it was."), +('refused-stays-confirmed', SAGA, "merge.State = AccountMergeState.Failed;", "merge.State = AccountMergeState.Confirmed;"), +('unknown-no-attempt', SAGA, " merge.Attempts++;\n", ""), +('unknown-no-backoff', SAGA, "merge.NextAttemptUtc = now + Backoff[Math.Min(merge.Attempts, Backoff.Length) - 1];", "merge.NextAttemptUtc = null;"), +('no-digest-check', SAGA, "if (!string.Equals(plan.Digest(request.SurvivorUserAccountId, request.FoldedUserAccountId), request.PreviewDigest, StringComparison.Ordinal))", "if (false)"), +('no-proof-account-check', SAGA, "if (request.SurvivorProof.HeimdallAccountId != survivorHeimdall || request.FoldedProofs.Any(x => x.HeimdallAccountId != foldedHeimdall))", "if (false)"), +('no-notparty', SAGA, "if (request.ActorUserAccountId != request.SurvivorUserAccountId && request.ActorUserAccountId != request.FoldedUserAccountId)", "if (false)"), +('no-block-check', SAGA, "if (plan.Blocks.Count > 0)", "if (false)"), +('no-grace-check', SAGA, "if (merge.GraceEndsUtc is not { } graceEnds || now >= graceEnds)", "if (merge.GraceEndsUtc is not { } graceEnds)"), +('no-notsurvivor', SAGA, "if (actorUserAccountId != merge.SurvivorUserAccountId)", "if (false)"), +('no-undo-proof-check', SAGA, "if (survivorProofs.Any(x => x.HeimdallAccountId != survivorHeimdall))", "if (false)"), +('no-final-graceover', SAGA, "if (merge.State == AccountMergeState.Final || merge.FinalizedUtc is not null)", "if (false)"), +('no-receipt-check', SAGA, "if (moved.Receipt.AccountId != destination)", "if (false)"), +('no-concurrency-retry', SAGA, "catch (DbUpdateConcurrencyException) when (lost < 2)", "catch (DbUpdateConcurrencyException) when (false)"), +('no-live-violation-catch', SAGA, "catch (DbUpdateException error) when (IsLiveMergeViolation(error))", "catch (DbUpdateException error) when (false)"), +('undo-refused-fails', SAGA, " merge.State = AccountMergeState.Committed;\n merge.LastOutcome = \"undo_\" + code;", " merge.State = AccountMergeState.Failed;\n merge.LastOutcome = \"undo_\" + code;"), +('unreadable-no-delay', SAGA, "merge.NextAttemptUtc = now + UnreadableRetry;", "merge.NextAttemptUtc = null;"), +('due-ignores-next-attempt', SAGA, "&& (x.NextAttemptUtc == null || x.NextAttemptUtc <= now)", ""), +('mismatch-retries', SAGA, " if (merge.LastOutcome == ReceiptMismatch)\n {\n return merge;", " if (false)\n {\n return merge;"), +('reverse-id-forward', SAGA, "new HeimdallMoveRequest(merge.HeimdallReverseMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId)", "new HeimdallMoveRequest(merge.HeimdallMoveId!, proofs.From, ReversesMoveId: merge.HeimdallMoveId)"), +('log-token', SAGA, 'logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code}", merge.Id, merge.State, outcome.GetType().Name, code);', 'logger.LogInformation("Account merge {MergeId} {State}: {Outcome} {Code} {Req}", merge.Id, merge.State, outcome.GetType().Name, code, proofs.From[0]);'), +('seal-plain', SAGA, "private string Seal(IEnumerable from, string? to) => _seal.Protect(JsonSerializer.Serialize(new ProofSeal([.. from], to)));", "private string Seal(IEnumerable from, string? to) => JsonSerializer.Serialize(new ProofSeal([.. from], to));"), +('sweeper-no-advance', LEDGER, " await scope.ServiceProvider.GetRequiredService().AdvanceDueAsync(now, stoppingToken);\n", ""), +('sweeper-no-finalize', LEDGER, " await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(now, stoppingToken);\n", ""), +('no-writegate-confirm', SAGA, "return await AccountMergeLedger.WriteAsAsync(dbContext, request.ActorUserAccountId, _ => InsertConfirmedAsync(request, cancellationToken), cancellationToken);", "return await InsertConfirmedAsync(request, cancellationToken);"), +('digest-no-entries', LEDGER, " foreach (var entry in Entries)\n {\n Field(entry.Kind.ToString());", " foreach (var entry in Entries.Take(0))\n {\n Field(entry.Kind.ToString());"), +('tier-swapped', SAGA, "merge.ProofFromTier = Tier(moved.Receipt.ProofFrom);", "merge.ProofFromTier = \"strong\";"), +('wipe-missing-on-reversed', SAGA, " merge.State = AccountMergeState.HeimdallReversed;\n merge.ReversedUtc = now;", " merge.State = AccountMergeState.HeimdallReversed;\n merge.ReversedUtc = now;\n merge.SealedProofs = \"kept\";"), +('grace-boundary', SAGA, "now >= graceEnds", "now > graceEnds"), +('move-id-not-unique', 'src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs', 'column: "HeimdallMoveId",\n unique: true,', 'column: "HeimdallMoveId",\n unique: false,'), +('reverse-id-not-unique', 'src/Bifrost.Web/Data/Migrations/20261114130000_AddAccountMergeSaga.cs', 'column: "HeimdallReverseMoveId",\n unique: true,', 'column: "HeimdallReverseMoveId",\n unique: false,'), +('digest-includes-created-id', LEDGER, 'entry.Action == AccountMergeEntryAction.Created ? null : entry.RecordId.ToString("N")', 'entry.RecordId.ToString("N")'), +('proofseal-prints-bearer', SAGA, 'builder.Append($"From = {From?.Count}, To = {(To is null ? "none" : "present")}");', 'builder.Append($"From = {string.Join(",", From)}, To = {To}");'), +] +only=sys.argv[1:] +def run(filt,log): + env=dict(os.environ,DOTNET_USE_POLLING_FILE_WATCHER='1') + r=subprocess.run([*os.environ.get('DOTNET','dotnet').split(),'test','tests/Bifrost.Web.Tests','--filter',filt],cwd=WT,env=env,capture_output=True,text=True) + open(log,'w').write(r.stdout+r.stderr) + out=r.stdout+r.stderr + fails=sorted(set(re.findall(r'^\s*Failed (Bifrost\.\S+)',out,re.M))) + built='Build FAILED' not in out and 'error CS' not in out + return built,fails,out +res={} +for name,path,old,new in M: + if only and name not in only: continue + full=os.path.join(WT,path) + src=open(full).read() + pairs=old if isinstance(old,list) else [(old,new)] + if any(src.count(o)!=1 for o,_ in pairs): + print(name,'ANCHOR',flush=True); res[name]='anchor-miss'; continue + mutated=src + for o,n in pairs: mutated=mutated.replace(o,n) + open(full,'w').write(mutated) + try: + built,fails,out=run(FILTER,f'{LOGS}/{name}.log') + if not built: res[name]='BUILD-FAIL' + elif not fails: res[name]='SURVIVED' + else: + # rerun the first failing test in isolation + t=fails[0]; method=t.split('.')[-1].split('(')[0] + built2,fails2,_=run('FullyQualifiedName~'+method,f'{LOGS}/{name}.iso.log') + res[name]=('KILLED x%d; isolated %s: %s'%(len(fails),method,'still fails' if fails2 else 'PASSES ALONE (order-dependent)')) + finally: + open(full,'w').write(src) + print(name,res[name],flush=True) +json.dump(res,open(LOGS+'/result.json','w'),indent=1) +print('MUT-DONE',flush=True) 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; +} diff --git a/tools/persona-chat-documents.mjs b/tools/persona-chat-documents.mjs index 7dc790d..afdc7f3 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", @@ -20,6 +22,8 @@ export const kinds = Object.freeze({ refusal: "gamecult.persona.chat_refusal.v1", openDirectRequest: "gamecult.persona.chat_open_direct_request.v1", openDirectReceipt: "gamecult.persona.chat_open_direct_receipt.v1", + fetchRequest: "gamecult.persona.chat_fetch_request.v1", // served only when the mouth's CHAT_FETCH flag is on, else an unknown kind + fetchReceipt: "gamecult.persona.chat_fetch_receipt.v1", }); export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom", "EmptyText", "SendFailed", "UnknownPersona"]); @@ -33,13 +37,20 @@ const refusalDocument = Object.freeze([...refusals, "UnknownKind"]); // One outcome word per open-direct receipt. room_id and event_id are present exactly for Opened and Existing. export const directOutcomes = Object.freeze(["Opened", "Existing", "Brake", "PersonaMismatch", "UnknownPersona", "EmptyText", "ForeignUser", "NotAPerson", "NoSuchUser", "InvitePending", "Declined", "DailyLimit", "SendFailed"]); +// One outcome word per fetch receipt, closed: no mouth text travels. room_id and image are present exactly for Stored and StoredUnverified; +// the receipt slot is nil, Posted or Existing and is non-nil only for Stored. +export const fetchOutcomes = Object.freeze(["Stored", "StoredUnverified", "Brake", "PersonaMismatch", "UnknownPersona", "NotAMember", "BridgedRoom", "TooLarge", "TypeRefused", "NoAttachment", "HashMismatch", "RateLimited", "FetchFailed", "StoreFailed"]); +export const fileReceivedType = "org.gamecult.file_received"; // the Matrix event the mouth posts as the Mind after it stored her file +export const fetchReceiptModes = Object.freeze(["Posted", "Existing"]); +const maxFetchIdBytes = 255; + const maxTextBytes = 4000; export const maxNewer = 50; 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) { @@ -57,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 }) { @@ -170,12 +181,44 @@ export function parseOpenDirectReceipt(t) { return { requestId: text(requestId, "request_id"), outcome, roomId: roomId === null ? null : text(roomId, "room_id"), eventId: eventId === null ? null : text(eventId, "event_id") }; } +// A Persona asks her mouth to keep one named Matrix file: room (an alias or a room id) and event id, nothing else. No mxc, URL, hash or path +// exists in the tuple; the mouth reads the event itself. The id shapes are the mouth's to refuse by word (NotAMember, NoAttachment); here only +// the bound that every id field has. +const fetchId = (v, what) => { + text(v, what); + if (Buffer.byteLength(v, "utf8") > maxFetchIdBytes) throw new Error(`${what} is over ${maxFetchIdBytes} bytes`); + return v; +}; +export function fetchRequestTuple({ requestId, persona, room, eventId }) { + return [text(requestId, "request_id"), text(persona, "persona"), fetchId(room, "room"), fetchId(eventId, "event_id")]; +} +export function parseFetchRequest(t) { + const [requestId, persona, room, eventId] = tuple(t, 4, "chat_fetch_request"); + return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), room: fetchId(room, "room"), eventId: fetchId(eventId, "event_id") }; +} + +function checkFetchReceipt(outcome, roomId, image, receipt) { + if (!fetchOutcomes.includes(outcome)) throw new Error(`unknown fetch outcome ${String(outcome)}`); + const stored = outcome === "Stored" || outcome === "StoredUnverified"; + if (stored !== (roomId !== null && image !== null) || (!stored && (roomId !== null || image !== null))) throw new Error("room_id and image are present exactly for Stored and StoredUnverified"); + if (receipt !== null && !(outcome === "Stored" && fetchReceiptModes.includes(receipt))) throw new Error("receipt is nil, or Posted or Existing for Stored only"); +} +export function fetchReceiptTuple({ requestId, outcome, roomId = null, image = null, receipt = null }) { + checkFetchReceipt(outcome, roomId, image, receipt); + return [text(requestId, "request_id"), outcome, roomId === null ? null : text(roomId, "room_id"), imageTuple(image), receipt]; +} +export function parseFetchReceipt(t) { + const [requestId, outcome, roomId, image, receipt] = tuple(t, 5, "chat_fetch_receipt"); + checkFetchReceipt(outcome, roomId, image, receipt); + return { requestId: text(requestId, "request_id"), outcome, roomId: roomId === null ? null : text(roomId, "room_id"), image: image === null ? null : parseImage(image), receipt }; +} + export function deliveryTuple(d) { return [ - seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), text(d.eventId, "event_id"), - text(d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"), - d.context.slice(-maxContext).map(c => [text(c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]), - d.mentions.map(id => text(id, "mention")), flag(d.isDirect, "is_direct"), imageTuple(d.image ?? null), + seq(d.seq, "seq"), text(d.roomAlias, "room_alias"), text(d.roomId, "room_id"), matrixId("$", d.eventId, "event_id"), + matrixId("@", d.sender, "sender"), text(d.senderDisplay, "sender_display"), String(d.text), seq(d.atUnixMs, "at_unix_ms"), + d.context.slice(-maxContext).map(c => [matrixId("@", c.sender, "sender"), String(c.text), seq(c.atUnixMs, "at_unix_ms")]), + d.mentions.map(id => matrixId("@", id, "mention")), flag(d.isDirect, "is_direct"), imageTuple(d.image ?? null), ]; } @@ -215,15 +258,46 @@ 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]) }; +} +// A Matrix id as the tuple carries it: its sigil, a non-empty rest, at most 255 bytes, no Unicode whitespace, control, format or private-use characters and no unpaired surrogates. +// The mouth applies the same test to what a homeserver returned, so a hostile id is no reply_to rather than a skipped delivery. +export const isMatrixId = (sigil, v) => typeof v === "string" && Buffer.byteLength(v) <= 255 && v.length > 1 && v[0] === sigil && !/[\p{White_Space}\p{Cc}\p{Cf}\p{Co}\p{Default_Ignorable_Code_Point}\uD800-\uDFFF]/u.test(v); +// A Matrix id the fetch path will put in a URL path or a request it builds: isMatrixId, and none of the characters or sequences that +// steer a path or a query (a slash, ?, #, %, backslash, ..). Stricter than isMatrixId on purpose: the event id and the album root are +// the sender's words. A room version 3 event id (standard base64) may hold a slash and is refused, as the fail-closed side. +export const isFetchId = (sigil, v) => isMatrixId(sigil, v) && !/[/?#%\\]/.test(v.slice(1)) && !v.includes(".."); +function replyTo(r) { + if (r === null) return null; + const { eventId, sender, excerpt = null } = r; + if (!isMatrixId("$", eventId)) throw new Error("reply_to event_id must be an event id ($..., at most 255 bytes)"); + if (!isMatrixId("@", sender)) throw new Error("reply_to sender must be a user id (@..., at most 255 bytes)"); + 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"); if (!Array.isArray(mentions)) throw new Error("delivery mentions must be a list"); return { - seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: text(eventId, "event_id"), - sender: text(sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"), - context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: text(cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }), - mentions: mentions.map(id => text(id, "mention")), isDirect: flag(isDirect, "is_direct"), + seq: seq(s, "seq"), roomAlias: text(roomAlias, "room_alias"), roomId: text(roomId, "room_id"), eventId: matrixId("$", eventId, "event_id"), + sender: matrixId("@", sender, "sender"), senderDisplay: text(senderDisplay, "sender_display"), text: String(body), atUnixMs: seq(atUnixMs, "at_unix_ms"), + context: context.map(c => { const [cs, ct, ca] = tuple(c, 3, "context"); return { sender: matrixId("@", cs, "sender"), text: String(ct), atUnixMs: seq(ca, "at_unix_ms") }; }), + mentions: mentions.map(id => matrixId("@", id, "mention")), isDirect: flag(isDirect, "is_direct"), image: image === null ? null : parseImage(image), }; } @@ -240,11 +314,11 @@ export function parsePending(t) { // One line of a room's recent talk, newest-unacked: room alias (or room id for a DM), event id, origin time, sender, form, basis, body. // Data for the reader, never an instruction: no delivery, no wake, no ack position hangs on it. export function tailLineTuple(l) { - return [text(l.roomAlias, "room"), text(l.eventId, "event_id"), seq(l.atUnixMs, "at_unix_ms"), text(l.sender, "sender"), form(l.form), basis(l.basis ?? null), String(l.text)]; + return [text(l.roomAlias, "room"), matrixId("$", l.eventId, "event_id"), seq(l.atUnixMs, "at_unix_ms"), matrixId("@", l.sender, "sender"), form(l.form), basis(l.basis ?? null), String(l.text)]; } function parseTailLine(t) { const [roomAlias, eventId, atUnixMs, sender, f, b, body] = tuple(t, 7, "tail_line"); - return { roomAlias: text(roomAlias, "room"), eventId: text(eventId, "event_id"), atUnixMs: seq(atUnixMs, "at_unix_ms"), sender: text(sender, "sender"), form: form(f), basis: basis(b), text: String(body) }; + return { roomAlias: text(roomAlias, "room"), eventId: matrixId("$", eventId, "event_id"), atUnixMs: seq(atUnixMs, "at_unix_ms"), sender: matrixId("@", sender, "sender"), form: form(f), basis: basis(b), text: String(body) }; } // The v3 answer: the persona, the deliveries, the tail (at most tailPerRoom lines a room, tailTotal in all, oldest first), then @@ -263,6 +337,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 { @@ -289,6 +375,10 @@ function tuple(t, n, what) { if (!Array.isArray(t) || t.length !== n) throw new Error(`${what} must be a ${n}-field tuple`); return t; } +function matrixId(sigil, v, what) { + if (!isMatrixId(sigil, v)) throw new Error(`${what} must be a Matrix id (${sigil}..., at most 255 bytes, no whitespace or control characters)`); + return v; +} function text(v, what) { if (typeof v !== "string" || v === "") throw new Error(`${what} must be a non-empty string`); return v; diff --git a/tools/persona-chat-images.mjs b/tools/persona-chat-images.mjs index 9e140e9..587327f 100644 --- a/tools/persona-chat-images.mjs +++ b/tools/persona-chat-images.mjs @@ -87,10 +87,10 @@ export function classify(bytes) { // GET the attachment named by `mxc` as this Persona: its token, its homeserver, the authenticated media path. The // body is read stream-wise and abandoned the moment it passes the cap, whatever Content-Length said. // Returns { bytes, mime, ext } or { refusal }. -export async function fetchImage({ fetch: httpFetch, homeserver, token, mxc, timeoutMs = FETCH_TIMEOUT_MS }) { +export async function fetchImage({ fetch: httpFetch, homeserver, token, mxc, timeoutMs = FETCH_TIMEOUT_MS, signal: outer }) { const named = typeof mxc === "string" ? MXC.exec(mxc) : null; if (!named) return { refusal: "no-url" }; - const signal = AbortSignal.timeout(timeoutMs); + const signal = outer ? AbortSignal.any([AbortSignal.timeout(timeoutMs), outer]) : AbortSignal.timeout(timeoutMs); // `outer`: the caller's deadline, joined with this fetch's own try { const response = await httpFetch(`${homeserver}/_matrix/client/v1/media/download/${named[1]}/${named[2]}`, { method: "GET", headers: { authorization: `Bearer ${token}` }, redirect: "error", signal, diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index c9767c3..0964830 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; 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 // (persona-chat-images.mjs): the delivery then carries a reference to the stored file, or the line says why not. @@ -64,6 +69,16 @@ const DM_SCAN = 200; // joined rooms read when looking for an existing DM with a const NOT_E2EE = "This room is not end-to-end encrypted: the homeserver can read what is said here."; const dayOf = ms => new Date(ms).toISOString().slice(0, 10); const TAIL_TTL_MS = 15 * 1000; +const FETCH_DEADLINE_MS = 15000; // one fetch, whole: it ends before Huginn's 20 s wait +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. @@ -121,7 +136,9 @@ export function createRoomGate() { 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, + presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS, + 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 }) { @@ -151,6 +168,7 @@ export function createMouth({ const processed = new Set(); // event ids read since the position last advanced const noticed = new Set(); // rooms this mouth created where the not-E2EE first line has gone out const personTails = new Map(); // person's user id -> the open in flight: opens for one person run one at a time + const fetchTimes = []; // when this Persona's recent fetches began: display-only rate count, reset by a restart const openedOn = new Map(); // person's user id -> the UTC day this process last created a room for them: one creation per person per day async function matrix(method, path, body, prefix = API, signal) { @@ -165,7 +183,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(); } @@ -287,6 +305,7 @@ export function createMouth({ const lines = []; let bytes = 0; for (const e of events.slice(0, cut).reverse()) { + if (!docs.isMatrixId("@", e.sender)) continue; // a line the tuple would refuse is left out, not the whole delivery const l = lineOf(e); const size = Buffer.byteLength(l.text) + Buffer.byteLength(l.sender); if (lines.length >= CONTEXT_LINES || bytes + size > CONTEXT_BYTES) break; @@ -296,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 @@ -310,22 +419,91 @@ 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 key = `${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 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; // target GETs one sync round may make, whoever asks (basis or reply_to): an event whose uncached targets would pass it waits for the next round + const KEEP_HIT_MS = 10 * 60 * 1000; // how long transient failures may keep a hit's sender alive after its last good read + let replyReads = 0; + const held = new Map(); // key -> entry: targets the round's cap check counted as cached, kept for that event so the 60 s keep cannot lapse between the check and the read and spend a GET the check did not count + const cachedTarget = key => { const known = authors.get(key); - if (known && (known.sender !== null || now() - known.at < NEGATIVE_MS)) return known.sender; - let sender = null; + if (!known) return null; + if (known.good !== undefined && now() - known.good > KEEP_HIT_MS) return null; // a renewed hit is never served past KEEP_HIT_MS after its last good read, whatever its age + return 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), 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 = /^>( |$)/; + 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\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\u034f\u115f\u1160\u17b4\u17b5\u180b-\u180f\u2065\u2800\u3164\ufe00-\ufe0f\uffa0\ufff0-\ufff8]|[\p{Cf}\p{Co}\u{E0000}-\u{E007F}\u{E0080}-\u{E00FF}\u{E0100}-\u{E0FFF}]|[\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) ?? held.get(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); + let sender = null, excerpt = null, transient = false; + replyReads++; 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 = docs.isMatrixId("@", target?.sender) ? target.sender : null; + 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}`); } - authors.set(key, { sender, 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, excerpt: null, at: t } // a transient failure (network, timeout, 5xx, 429) keeps the earlier hit's sender and renews only its age, for at most KEEP_HIT_MS after the last good read; never its excerpt (a re-read happens only after TARGET_TTL_MS, so the excerpt is already as old as a good read may be, and a renewed entry must not serve it again) + : { 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 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. + // The GETs an event could cost in targetOf: its uncached reply target and thread root. An over-count is harmless (the event waits a round); an under-count would pass the cap. + const targetsToRead = (roomId, event) => { + const relates = event.content["m.relates_to"]; + if (!replyLink || formOf(event.content) !== "message") return 0; + const ids = new Set([relates?.["m.in_reply_to"]?.event_id, relates?.rel_type === "m.thread" ? relates.event_id : undefined].filter(id => typeof id === "string" && id !== "")); + held.clear(); // events run one at a time: a pin serves only the event whose check made it + let reads = 0; + for (const id of ids) { + const key = `${roomId} ${id}`, hit = cachedTarget(key); + if (hit) held.set(key, hit); else reads++; + } + return reads; + }; + 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 || !docs.isMatrixId("$", id) ? 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 !== "")); @@ -364,6 +542,9 @@ 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 await setPresence(free); if (pending.length >= maxPending) return []; // unanswered mail is not outrun: the position stays where it is @@ -389,25 +570,30 @@ export function createMouth({ if ((room.timeline?.events ?? []).length > 0) tailReads.delete(roomId); for (const event of room.timeline?.events ?? []) { if (event.type !== "m.room.message" || typeof event.content?.body !== "string") continue; - if (typeof event.sender !== "string" || event.sender === "" || typeof event.event_id !== "string" || event.event_id === "") continue; + if (!docs.isMatrixId("@", event.sender) || !docs.isMatrixId("$", event.event_id)) continue; // an id the tuple would refuse is no event: it could not be delivered, and one such event must not wedge the round if (processed.has(event.event_id)) continue; // read already in a round the cap kept from advancing const ts = event.origin_server_ts; const atUnixMs = Number.isSafeInteger(ts) && ts >= 0 ? ts : now(); 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 + 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 && await followed(roomId, event)) basis = "follow"; + const replyTo = deliverable ? await replyToOf(roomId, event) : null; processed.add(event.event_id); if (deliverable) { const context = await contextFor(roomId, event, round); const { image = null, refused = null } = await imageOf(roomId, event.content); if (refused !== null) say(`image refused: ${refused}`); const mentioned = event.content["m.mentions"]?.user_ids; - const basis = await basisOf(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, + senderDisplay: displayNames.get(event.sender) ?? (localpart(event.sender) || 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), + mentions: Array.isArray(mentioned) ? mentioned.filter(id => docs.isMatrixId("@", id)).slice(0, docs.limits.mentions) : [], + isDirect: directRooms.has(roomId), basis, form: formOf(event.content), replyTo, }); if (image) images.hold(image.path, seqCounter); } @@ -513,7 +699,7 @@ export function createMouth({ if (truncated || events.length > docs.limits.tailPerRoom) gapped.add(alias); const lines = []; for (const e of events.slice(-docs.limits.tailPerRoom)) { - if (typeof e.event_id !== "string" || e.event_id === "") { gapped.add(alias); continue; } + if (!docs.isMatrixId("$", e.event_id) || !docs.isMatrixId("@", e.sender)) { gapped.add(alias); continue; } const at = Number.isSafeInteger(e.origin_server_ts) && e.origin_server_ts >= 0 ? e.origin_server_ts : 0; lines.push({ roomAlias: alias, eventId: e.event_id, atUnixMs: at, sender: e.sender, form: formOf(e.content), basis: await basisOf(roomId, e, false), text: bodyText(e.content, docs.limits.contextBytes) }); } @@ -567,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}`); @@ -672,6 +861,128 @@ export function createMouth({ } } + // A top-level refusal of the event itself, ported from Huginn's room_scope refuse_event: a sender whose localpart (after leading + // underscores) starts with the platform's name, or has it after an underscore, or followed by `bot` or an underscore; or a content + // key naming it. Identifier rules, not language reading. + const platformEvent = event => { + const local = String(event?.sender ?? "").replace(/^@+/, "").split(":")[0].toLowerCase(); + return local.replace(/^_+/, "").startsWith(PLATFORM) || local.includes(`_${PLATFORM}`) || local.includes(`${PLATFORM}bot`) || local.includes(`${PLATFORM}_`) + || Object.keys(event?.content ?? {}).some(key => key.toLowerCase().includes(PLATFORM)); + }; + const IMAGE_WORD = { "no-url": "NoAttachment", encrypted: "TypeRefused", "wrong-type": "TypeRefused", "bad-header": "TypeRefused", "too-many-pixels": "TooLarge", "too-big": "TooLarge", "fetch-failed": "FetchFailed", "store-failed": "StoreFailed" }; + + // A Persona asks her mouth to keep one named Matrix file. The request is a room and an event id; the mouth reads the event, takes + // its content.url, fetches and checks the bytes, stores them, and posts the contract's inert receipt as her. One deadline owns the + // whole of it. The answer is one fixed outcome word and, for a stored file, the resolved room id and the store reference. + async function fetchAttachment({ requestId, persona: asked, room, eventId }) { + const deadline = AbortSignal.timeout(fetchDeadlineMs); + const seen = { room: "-", event: "-", size: "-", type: "-", sha256: "-" }; + const idOr = (sigil, v) => docs.isFetchId(sigil, v) ? v : "-"; // a hostile id never reaches the log + let answered = false; // one log line per fetch: a gate step that finishes after the deadline answered is silent + const done = (outcome, { image = null, roomId = null, receipt = null, logged = "skipped", cause = null } = {}) => { + if (!answered) say(`fetch persona=${persona} room=${seen.room} event=${seen.event} outcome=${outcome} size=${seen.size} type=${seen.type} sha256=${seen.sha256} receipt=${logged}${cause === null ? "" : ` cause=${cause}`}`); + answered = true; + return { requestId, outcome, roomId, image, receipt }; + }; + if (asked !== persona) return done("PersonaMismatch"); + if (!(await released())) return done("Brake"); + if (!images) return done("StoreFailed"); + const at = now(); + while (fetchTimes.length > 0 && at - fetchTimes[0] >= 3_600_000) fetchTimes.shift(); + if (fetchTimes.length >= FETCH_PER_HOUR) return done("RateLimited"); + fetchTimes.push(at); + seen.event = idOr("$", eventId); + if (!docs.isFetchId("$", eventId)) return done("NoAttachment"); + if (!docs.isFetchId("!", room) && !docs.isFetchId("#", room)) return done("NotAMember"); + let stored = false; // images.put has run: a deadline after it leaves a file and no receipt + const aborted = new Promise((_, reject) => deadline.addEventListener("abort", () => reject(deadline.reason), { once: true })); + aborted.catch(() => {}); + const within = promise => Promise.race([promise, aborted]); + try { + if (!started || unjoined()) await within(start()); + const roomId = speakableRoomId(room) ?? room; + if (!docs.isFetchId("!", roomId) && !docs.isFetchId("#", roomId)) return done("NotAMember"); // an alias the directory answered with a strange id + seen.room = idOr("!", roomId); + return await within(gate.run(roomId, async () => { + deadline.throwIfAborted(); // re-checked after the gate wait: a queued fetch does not run past its deadline + if (!(await released())) return done("Brake"); // nor past a brake placed while it waited + const read = (method, path, body, prefix = API) => matrix(method, path, body, prefix, deadline); + const enc = encodeURIComponent; + const member = (await read("GET", "/joined_rooms")).joined_rooms; + if (!Array.isArray(member) || !member.includes(roomId)) return done("NotAMember"); + const state = await read("GET", `/rooms/${enc(roomId)}/state`); + if (!Array.isArray(state)) return done("FetchFailed"); + if (state.some(e => e?.type === "m.bridge" || e?.type === "uk.half-shot.bridge")) return done("BridgedRoom"); + if (state.some(e => e?.type === "m.room.encryption")) return done("TypeRefused"); + let event; + try { event = await read("GET", `/rooms/${enc(roomId)}/event/${enc(eventId)}`); } catch (error) { + if (error.status === 403 || error.status === 404) return done("NotAMember"); + throw error; + } + if (event?.room_id !== roomId) return done("NotAMember"); + if (!docs.isFetchId("$", event.event_id)) return done("NoAttachment"); // the receipt names the event the homeserver returned, not the one asked for + if (platformEvent(event)) return done("BridgedRoom"); + const content = event.content; + if (event.type === "m.room.encrypted") return done("TypeRefused"); + const relates = content?.["m.relates_to"]; + if (event.type !== "m.room.message" || content === null || typeof content !== "object" || Object.keys(content).length === 0 || relates?.rel_type === "m.replace") return done("NoAttachment"); + if (content.file !== undefined) return done("TypeRefused"); + if (content.msgtype === "m.audio" || content.msgtype === "m.video") return done("TypeRefused"); + if (content.msgtype !== "m.image" && content.msgtype !== "m.file") return done("NoAttachment"); + if (typeof content.url !== "string" || !content.url.startsWith("mxc://")) return done("NoAttachment"); + // The album root is the sender's word: it names a URL path below, so it must be an event id or nothing is built from it. + const albumRoot = relates?.rel_type === "m.thread" ? relates.event_id : null; + if (relates?.rel_type === "m.thread" && !docs.isFetchId("$", albumRoot)) return done("NoAttachment"); + const got = await fetchImage({ fetch: httpFetch, homeserver, token, mxc: content.url, signal: deadline }); + deadline.throwIfAborted(); + if (got.refusal) return done(IMAGE_WORD[got.refusal] ?? "FetchFailed"); + const computed = createHash("sha256").update(got.bytes).digest("hex"); + Object.assign(seen, { size: got.bytes.length, type: got.mime, sha256: computed }); + const info = content.info; + const claimed = info !== null && typeof info === "object" && !Array.isArray(info) && Object.hasOwn(info, "sha256"); + if (claimed && info.sha256 !== computed) return done("HashMismatch"); + deadline.throwIfAborted(); + let image; + try { image = images.put(roomId, got.bytes, got.mime); } catch (error) { return done("StoreFailed", { cause: causeOf(error) }); } + stored = true; + if (!claimed) return done("StoredUnverified", { image, roomId }); + const { receipt, word, cause } = await postFileReceipt(roomId, albumRoot, event.event_id, computed, got.bytes.length, deadline); + return done("Stored", { image, roomId, receipt, logged: word, cause }); + })); + } catch (error) { + return done("FetchFailed", { logged: stored ? "failed" : "skipped", cause: causeOf(error) }); + } + } + + // The contract's receipt for a file the mouth stored, posted as her own user: a thread reply when the file is in an album thread, + // else a plain event referencing the file. Idempotence is Matrix's: her own earlier receipt in the target's relations. A relations + // read or a PUT that fails, a held brake, proves nothing and posts nothing: the file stays stored and the receipt is nil. A deadline + // is not such a failure and leaves the caller (it throws). + async function postFileReceipt(roomId, albumRoot, fileEventId, sha256, size, deadline) { + const none = (word, cause = null) => ({ receipt: null, word, cause }); + try { + const room = encodeURIComponent(roomId); + const [target, relation] = albumRoot === null ? [fileEventId, "m.reference"] : [albumRoot, "m.thread"]; + let from = null, exhausted = false; + for (let page = 0; page < RELATION_PAGES && !exhausted; page++) { + const reply = await matrix("GET", `/rooms/${room}/relations/${encodeURIComponent(target)}/${relation}/${docs.fileReceivedType}?dir=b&limit=100${from === null ? "" : `&from=${encodeURIComponent(from)}`}`, undefined, V1, deadline); + const mine = (reply?.chunk ?? []).some(e => e?.type === docs.fileReceivedType && e.sender === selfId && !e.unsigned?.redacted_because && e.content?.[docs.fileReceivedType]?.event_id === fileEventId); + if (mine) return { receipt: "Existing", word: "existing" }; + if (typeof reply?.next_batch === "string" && reply.next_batch !== "") from = reply.next_batch; else exhausted = true; + } + if (!exhausted) return none("failed"); // three pages and more remain: not proven + if (!(await released())) return none("skipped"); // read again immediately before the PUT, as send does inside its gate + deadline.throwIfAborted(); + const relates = albumRoot === null ? { rel_type: "m.reference", event_id: fileEventId } : { rel_type: "m.thread", event_id: albumRoot }; + const txn = `fr-${createHash("sha256").update(`${persona}|${fileEventId}`).digest("hex")}`; + await matrix("PUT", `/rooms/${room}/send/${docs.fileReceivedType}/${txn}`, { "m.relates_to": relates, [docs.fileReceivedType]: { event_id: fileEventId, sha256, size } }, API, deadline); + return { receipt: "Posted", word: "posted" }; + } catch (error) { + if (deadline.aborted) throw error; + return none("failed", causeOf(error)); + } + } + async function run(signal) { const pause = ms => new Promise(r => { const t = setTimeout(r, ms); signal?.addEventListener("abort", () => { clearTimeout(t); r(); }, { once: true }); }); while (!signal?.aborted) { @@ -685,7 +996,14 @@ export function createMouth({ } } - return { start, syncOnce, ack, read, pendingAfter, tail, speakableRooms, send, openDirect, 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). +function causeOf(error) { + return `${/^[A-Za-z]{1,32}$/.test(error?.name) ? error.name : "Error"}/${Number.isInteger(error?.status) ? error.status : "-"}`; } // Replace every secret in a message. `secrets` is one string or a list. @@ -730,15 +1048,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 +1088,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, fetchEnabled = 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 +1100,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, ...(fetchEnabled ? [docs.kinds.fetchRequest] : [])]; const handle = async ({ peer, channelId, payload }) => { try { if (channelId !== "schema") throw new Error("chat request arrived on a foreign channel"); @@ -798,13 +1117,19 @@ 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.fetchRequest) { // only reachable with fetchEnabled: `served` drops it otherwise + const request = docs.parseFetchRequest(tuple); + const mouth = mouths.get(request.persona); + const receipt = mouth ? await mouth.fetchAttachment(request) : { requestId: request.requestId, outcome: "UnknownPersona", roomId: null, image: null, receipt: null }; + respond(peer, request.persona, docs.kinds.fetchReceipt, receipt.requestId, docs.fetchReceiptTuple(receipt)); + } 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 +1181,13 @@ 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"; +// 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 variant is Basis::FollowUp, wire string "follow", FOLLOW_UP_BASIS f09bc56e). +export const followFromEnv = env => env.CHAT_FOLLOW === "1"; + function required(env, name) { if (!env[name]) throw new Error(`${name} is required`); return env[name]; @@ -903,10 +1235,12 @@ 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 fetchEnabled = fetchFromEnv(env); + 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, 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 }), @@ -914,7 +1248,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, fetchEnabled }); await Promise.all([...mouths.values()].map(mouth => mouth.run())); }); }