diff --git a/AGENTS.md b/AGENTS.md index c06e133..0b17617 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,7 @@ ## Locked Technical Defaults - stack: ASP.NET Core 8 LTS -- UI: Razor Pages + HTMX +- UI: Razor Pages - database: PostgreSQL - hosting target: `bifrost.gamecult.org` on Yggdrasil - deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops` diff --git a/README.md b/README.md index 0e4ea9d..8aaa457 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m ## Chosen Stack - ASP.NET Core 8 LTS -- Razor Pages + HTMX +- Razor Pages - PostgreSQL - Bifrost-native identity registration - GitHub OAuth for linked sign-in and repository integration @@ -73,7 +73,7 @@ For the full staged roadmap, including contribution points, revenue share, patro ## Local Dev Notes - set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in -- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access +- POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access - set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path - HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`. - external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed. @@ -89,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production - default connection string points at local PostgreSQL - build with `dotnet build Bifrost.slnx` -- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed +- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT` - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` - export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc` diff --git a/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 d3a07c9..b4cf73c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -23,7 +23,7 @@ That means Bifrost owns the operational meaning of the crossing: It does not mean Bifrost should own OAuth sludge. Bifrost is both an identity provider and identity consumer: a GameCult actor should be able to register with Bifrost without OAuth, and that Bifrost identity should be enough to authenticate with most GameCult services. Heimdall owns outside-provider OAuth, linked-account token custody, grants, and signed account claims. Bifrost associates those Heimdall-backed accounts with the Bifrost identity, consumes the permission facts, and decides whether a bridge action is allowed in the Bifrost domain. -The native registration surface is `POST /auth/bifrost/register`. It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody. +The native registration surface is `POST /auth/bifrost/register`, which takes `identity`, optional `displayName` and `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (from `GET /auth/bifrost/notice`). It creates a Bifrost-owned `UserAccount` with `BifrostIdentity` and `NormalizedBifrostIdentity`, signs the caller in with the Bifrost cookie, and leaves membership at `Authenticated`. It does not grant active member access, mint outside-provider tokens, or replace Heimdall's account-link and capability custody. ## Ownership @@ -196,7 +196,90 @@ 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`) + +The mouth wakes no one; the heartbeat and each Persona session decide who is told a delivery (Huginn `address.rs`, ruling `eureka-body:ruling:addressed-delivery-only`). Telling every Persona every message made 71.5% of 4,351 Persona turns on 2026-11-10 do nothing. The rule stays in one place, Huginn's, because two of its inputs are not Matrix facts: the Persona's stewardship records (her projects' chat names) and the sender's role (operator or Mind). The mouth owns the other half: what Matrix itself says, per Persona, for the delivery. Matrix verifies the sender's identity and nothing else: `tag` and `room` are the sender's claim in `m.mentions` (the homeserver does not check the text against it, nor that the sender may use `@room`), and `reply` is checked against the event it targets, which the mouth reads as her account. + +A client asks for the facts by the kind it polls with: `gamecult.persona.chat_pending_request.v2` (the v1 tuple, `[persona, after_seq]`) is answered with `gamecult.persona.chat_pending.v3`; a v1 request still gets the 12-field `chat_pending.v2` answer unchanged. A v3 delivery is the 12 fields of the v2 delivery, then: + +| Field | Type | Meaning | +|---|---|---| +| 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. + +Proposed runtime half (follow-up `persona-wakes-on-address-only`): + +1. Poll with `chat_pending_request.v2` and parse `chat_pending.v3`. +2. A delivery is *addressed* to a Persona when `form == "message"` and either `basis` is `direct`, `tag` or `reply`, or `address::addresses` matches her names or her projects' names on the text. The off-beat wake keeps the sender gate of `heartbeat.rs` `wakes` (the operator or another Mind of the roster): a stranger's tag, `@room` or DM is delivered but wakes no one off the beat. `basis == "room"` is the runtime's policy to weigh; the mouth only reports it. +3. A real-time wake, a live session's tell and the stagger apply to addressed deliveries only. A `notice` or an `edit` never wakes, whatever its basis. +4. A delivery that is not addressed is acked in sequence with the rest, so the position moves (the mouth stops reading at 500 unacked deliveries); it is not lost: Matrix is the record, and the next addressed delivery's `context` (up to 20 lines) and a beat's own room read show it. The room tail below gives a scheduled beat those lines without a Matrix read of its own; the mouth adds no endpoint for it. +5. `basis` and `form` are evidence, not authority: a sender's claim is weighed by the runtime's own rule (`address.rs` for names, the sender gate for who may wake her); the mouth reports what the event says and what only Matrix could look up (the author of a reply target). + +### Room tail (`chat_pending.v3`, field 2) + +The v3 answer is `[persona, deliveries, tail, tail_gaps]`. `tail` is a list of recent room lines, oldest first, for a reader that was not woken for them (a scheduled beat). It uses the same room read as a delivery's `context` (the same function, a separate read), as her account, with no new endpoint; the cost is one `/messages` call per speakable room per poll (15 seconds cached), including every DM. A `chat_pending_request.v1` is still answered with the two-field `chat_pending.v2`. `chat_pending.v3` has not been deployed, but a reader that decodes it as a two-field tuple fails on the third element (rmp-serde `LengthMismatch`), so Huginn must accept `tail` and `tail_gaps` (defaulted fields) before this mouth is deployed. The array is positional: a decoder of the earlier three-field shape fails on the fourth element with the same `LengthMismatch`, so `tail_gaps` has the same deploy precondition; a decoder that reads the first three elements and ignores the rest sees `persona`, `deliveries` and `tail` unchanged. + +| Bound | Rule | +|---|---| +| Rooms | Only rooms the Persona may speak in: a configured room she has joined, or one of her DMs. The same rule `send` uses (`speakableRoomId`). A room she is in but cannot speak in, and any other room, is absent. | +| Lines | A text line outside threads, not an edit, newest `20` per room, newest `60` over all rooms. Trimmed oldest first if the reply byte budget (256 KiB, deliveries first) would be exceeded. | +| Cursor | None. The tail is each room's newest lines whether or not they were delivered or acked: a message the wake rule left unaddressed is acked and leaves `pending`, and the tail is where a beat still finds it. A reader removes the lines a delivery already shows (by event id). Her own lines are included. | +| Read | A room's read is kept 15 seconds, dropped when a sync brings the room news. A room that cannot be read within 10 seconds is logged and absent; the other rooms and the deliveries are unaffected. | + +A line is the 7-field tuple `[room, event_id, at_unix_ms, sender, form, basis, body]`: `room` is the configured alias (the room id for a DM), `form` and `basis` as for a delivery, `body` clipped to 1000 bytes with the same typed text line for media a delivery has (nothing is fetched). `basis` is what Matrix says on the event itself (`direct` in a DM, `tag`, `room`) and nothing the mouth would have to look up, so a reply is nil here: **nil is unproven, not "not for her"**, and a line in a DM is `direct`. Huginn's rule that an `is_direct` delivery wakes with a nil `basis` is unchanged and lives with Huginn; the tail does not feed it. + +The tail is **data, not instruction**. A line is what a sender typed in a room, and the mouth wakes no one, acks nothing and sends nothing because of it. A reader treats the body as untrusted text and weighs `basis` as evidence, as for a delivery. The tail does not replace a delivery: a message still reaches her as a delivery, with its own `seq`, and only `chat_ack` advances a position. + +#### Gaps in the tail (`chat_pending.v3`, field 3, `tail_gaps`) + +`tail_gaps` is a list of room names (the configured alias, or the room id for a DM, as `room` in a tail line), one for each speakable room whose lines are not all in `tail`. A reader renders such a room as **unknown**, not quiet. Absent or empty means every speakable room was read fully: a room with no lines because it is quiet, and a room whose few lines all fit, are never listed. Order is the order the mouth reads rooms (configured rooms in join order, then DMs), not time; a room appears once however many causes apply. Causes, all of them: + +| Cause | Where | +|---|---| +| The room's read failed (the homeserver refused or errored) or did not answer within 10 seconds (`sendTimeoutMs`); the room is logged and contributes no lines | the mouth | +| The room had more than 20 lines outside threads (the per-room cap; her own lines count), so older ones were left out | the mouth | +| The read window came back full (100 raw events, before edits and thread replies are set aside), so real lines older than the window may exist and are unseen; a shorter chunk means the room's history was exhausted. A room whose history is exactly 100 events long is gapped too, since the mouth cannot tell. The rule assumes a homeserver returns the 100 events asked for whenever older history exists; a server that caps the limit lower turns a truncated read into a false "whole", and Bifrost does not detect that | the mouth | +| One of the room's lines was cut by the 60-line total cap (only the newest 60 over all rooms are kept) | the mouth | +| A line was dropped as malformed (no event id) | the mouth | +| The reply byte budget (256 KiB, deliveries first) cut the room's oldest lines, or a line would not encode and was skipped | the reply | +| The mouth has not started: every configured room is a gap (DMs are not yet known) | the mouth | +| The whole tail read failed: every speakable room is a gap, and the deliveries are still served | the endpoint | + +A line a delivery already shows and a room she cannot speak in change nothing here; her own lines are lines like any other and count toward the caps. A read the mouth kept for 15 seconds counts as a read. + +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. @@ -228,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 91c92e6..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/Membership/BifrostIdentityService.cs b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs index af6596e..aa1bf8a 100644 --- a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs +++ b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs @@ -12,10 +12,11 @@ public sealed partial class BifrostIdentityService( BifrostDbContext dbContext, TimeProvider timeProvider) { - public async Task RegisterNativeAsync( - string identity, - string? displayName, - CancellationToken cancellationToken) + /// + /// The one rule set for a new native identity: well formed, not reserved, not taken. Returns the normalized identity or + /// throws . Spends nothing; the unique index at insert stays the race backstop. + /// + public async Task CheckAvailableAsync(string identity, CancellationToken cancellationToken) { var normalizedIdentity = NormalizeIdentity(identity); if (IsReserved(normalizedIdentity)) @@ -23,14 +24,22 @@ public sealed partial class BifrostIdentityService( throw new BifrostIdentityException("That Bifrost identity is reserved."); } - var exists = await dbContext.UserAccounts.AnyAsync( - x => x.NormalizedBifrostIdentity == normalizedIdentity, - cancellationToken); - if (exists) + if (await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity, cancellationToken)) { throw new BifrostIdentityException("That Bifrost identity is already registered."); } + return normalizedIdentity; + } + + public async Task RegisterNativeAsync( + string identity, + string? displayName, + int? noticeVersion, + CancellationToken cancellationToken) + { + var normalizedIdentity = await CheckAvailableAsync(identity, cancellationToken); + var now = timeProvider.GetUtcNow(); var publicIdentity = ToPublicIdentity(normalizedIdentity); var resolvedDisplayName = string.IsNullOrWhiteSpace(displayName) @@ -57,11 +66,42 @@ public sealed partial class BifrostIdentityService( } }; + // One SaveChanges: an account that agreed never exists without its receipt. No version, no receipt. dbContext.UserAccounts.Add(userAccount); - await dbContext.SaveChangesAsync(cancellationToken); + if (noticeVersion is { } agreed) + { + dbContext.ConsentReceipts.Add(new ConsentReceipt + { + UserAccount = userAccount, + NoticeVersion = agreed, + AcceptedAtUtc = now + }); + } + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // Only a lost race on the unique index is the caller's mistake; anything else is a fault and propagates. + if (!await IdentityTakenAsync(normalizedIdentity)) + { + throw; + } + + throw new BifrostIdentityException("That Bifrost identity is already registered."); + } + return userAccount; } + // A lost race on the unique index: the tracked insert is dropped and the identity is read fresh. + private async Task IdentityTakenAsync(string normalizedIdentity) + { + dbContext.ChangeTracker.Clear(); + return await dbContext.UserAccounts.AnyAsync(x => x.NormalizedBifrostIdentity == normalizedIdentity); + } + public async Task EnsureIdentityAsync( UserAccount userAccount, string preferredIdentity, diff --git a/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs new file mode 100644 index 0000000..870ca4f --- /dev/null +++ b/src/Bifrost.Web/Features/Membership/MembershipSuspensionService.cs @@ -0,0 +1,81 @@ +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; + +namespace Bifrost.Web.Features.Membership; + +public enum SuspensionOutcome +{ + Done, + NotAdmin, + NoReason, + NoSuchAccount, + NotApplicable, + ProtectedAccount +} + +/// +/// 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 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. +/// +public sealed class MembershipSuspensionService(BifrostDbContext dbContext, AuditTrailService auditTrailService) +{ + public const int MaxReasonLength = 500; + + public Task SuspendAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, CancellationToken cancellationToken) => + ChangeAsync(actor, userAccountId, reason, suspend: true, cancellationToken); + + public Task UnsuspendAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, CancellationToken cancellationToken) => + ChangeAsync(actor, userAccountId, reason, suspend: false, cancellationToken); + + private async Task ChangeAsync(CurrentBifrostActor actor, Guid userAccountId, string? reason, bool suspend, CancellationToken cancellationToken) + { + if (!actor.CanManageMembership || actor.UserAccount is null) + { + return SuspensionOutcome.NotAdmin; + } + + if (string.IsNullOrWhiteSpace(reason)) + { + return SuspensionOutcome.NoReason; + } + + var user = await dbContext.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + if (user?.Membership is not { } membership) + { + return SuspensionOutcome.NoSuchAccount; + } + + // 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)) + { + return SuspensionOutcome.ProtectedAccount; + } + + if (suspend == (membership.Status == MembershipStatus.Suspended)) + { + return SuspensionOutcome.NotApplicable; + } + + var before = membership.Status; + 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(); + 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}).", + cancellationToken); + return SuspensionOutcome.Done; + } +} diff --git a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs index 8ee13a0..6d41d73 100644 --- a/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs +++ b/src/Bifrost.Web/Features/Membership/MembershipSynchronizationService.cs @@ -12,7 +12,9 @@ public sealed class MembershipSynchronizationService( BifrostDbContext dbContext, BifrostIdentityService bifrostIdentityService, IOptions bootstrapOptions, - TimeProvider timeProvider) + TimeProvider timeProvider, + AuditTrailService auditTrailService, + ILogger logger) { public async Task SynchronizeAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) { @@ -109,9 +111,14 @@ public sealed class MembershipSynchronizationService( invitation.AcceptedAtUtc = now; } + MembershipStatus? promotedFrom = null; if (IsBootstrapAdmin(gitHubLogin, bootstrapOptions.Value)) { - PromoteToBootstrapAdmin(userAccount.Membership!, now); + promotedFrom = userAccount.Membership!.Status; + if (!ApplicationBootstrapper.TryPromoteBootstrapAdmin(userAccount, now, logger)) + { + promotedFrom = null; + } } else if (userAccount.Membership!.Status == MembershipStatus.Active) { @@ -125,22 +132,16 @@ public sealed class MembershipSynchronizationService( userAccount.MemberProfile!.UpdatedAtUtc = now; + if (promotedFrom is { } before && before != userAccount.Membership!.Status) + { + // One save: the promotion and its receipt stand or fall together. + await ApplicationBootstrapper.RecordPromotionAsync(auditTrailService, userAccount, before, cancellationToken); + return; + } + await dbContext.SaveChangesAsync(cancellationToken); } - private static void PromoteToBootstrapAdmin(Bifrost.Web.Domain.Membership membership, DateTimeOffset now) - { - membership.Status = MembershipStatus.Active; - membership.IsPlatformAdmin = true; - membership.CanManageProjects = true; - membership.CanManageLedger = true; - membership.CanModerateMotions = true; - membership.ApprovedAtUtc ??= now; - - ApplicationBootstrapper.EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin"); - ApplicationBootstrapper.EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role"); - } - private static bool TryReadGitHubIdentity( ClaimsPrincipal principal, out long gitHubUserId, diff --git a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs index edf1dd3..c393afd 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs @@ -14,10 +14,17 @@ public sealed class OidcClientSeeder( IServiceScopeFactory scopeFactory, OidcIssuerOptions options, OidcKeyMaterial material, - IOptions serverOptions) : IHostedService + IOptions serverOptions, + ILogger logger) : IHostedService { public async Task StartAsync(CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) + { + logger.LogWarning( + "Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is not set: suspending an account stops new sign-ins and token refresh but does not end its existing chat sessions."); + } + // Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request. _ = serverOptions.Value; diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs index b435347..a789882 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -2,6 +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; @@ -19,8 +22,10 @@ namespace Bifrost.Web.Features.Oidc; /// public static class OidcEndpoints { - public const string NotActiveDescription = "The account is not an active GameCult member."; + public const string SuspendedDescription = "The account is suspended."; public const string NoIdentityDescription = "The account has no Bifrost identity."; + public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved."; + public const string ConsentRequiredDescription = "Agreement to the training notice in force is needed to speak in a recorded room."; private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; private const string IdentityAuthenticationType = "Bifrost.Oidc"; @@ -32,7 +37,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."); @@ -47,9 +52,16 @@ public static class OidcEndpoints } var actor = await actors.GetAsync(http.RequestAborted); - if (actor.UserAccount is null || !actor.IsActiveMember) + // Chat tier: any account that is not suspended may sign in to chat. Membership is neither required nor written; + // 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, NotActiveDescription); + return Deny(Errors.AccessDenied, NoIdentityDescription); + } + + if (actor.Membership?.Status == MembershipStatus.Suspended) + { + return Deny(Errors.AccessDenied, SuspendedDescription); } var account = actor.UserAccount; @@ -58,6 +70,13 @@ public static class OidcEndpoints return Deny(Errors.AccessDenied, NoIdentityDescription); } + // The identity becomes the Matrix localpart. Creation refuses reserved names (BifrostIdentityService), but a row minted + // before that refusal may hold one, and there is no rename path: the issuer never vouches for a Mind's or a system's localpart. + if (BifrostIdentityService.IsReserved(account.NormalizedBifrostIdentity)) + { + return Deny(Errors.AccessDenied, ReservedIdentityDescription); + } + var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role); identity.SetClaim(Claims.Subject, account.Id.ToString("D")); identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity); @@ -67,6 +86,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], @@ -75,19 +101,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; membership is read again here, - // because the code outlives the authorize decision (a member 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 IsStillActiveMemberAsync(db, subject, http.RequestAborted)) + if (await ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal) { - return Deny(Errors.InvalidGrant, NotActiveDescription); + return Deny(Errors.InvalidGrant, refusal); } return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); @@ -101,10 +140,10 @@ public static class OidcEndpoints return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); } - // An access token outlives the authorize decision too: a member suspended since is no longer answered. - if (!await IsStillActiveMemberAsync(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, NotActiveDescription); + return Deny(Errors.InsufficientAccess, refusal); } var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; @@ -117,18 +156,41 @@ public static class OidcEndpoints return Results.Json(claims); } - /// The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler. - internal static async Task IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + /// + /// The one reading of "this subject may still chat", shared by the token and userinfo endpoints and the logout reconciler: + /// 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 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 new CurrentBifrostActor(true, account).IsActiveMember; + if (account is null || account.Membership?.Status == MembershipStatus.Suspended) + { + return SuspendedDescription; + } + + 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. + internal static async Task ActiveMemberIdAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken) + { + if (!Guid.TryParse(subject, out var accountId)) + { + return null; + } + + var account = await db.UserAccounts.AsNoTracking() + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); + return new CurrentBifrostActor(true, account).IsActiveMember ? accountId : null; } private static IResult Deny(string error, string description) => diff --git a/src/Bifrost.Web/Features/Oidc/OidcGuard.cs b/src/Bifrost.Web/Features/Oidc/OidcGuard.cs new file mode 100644 index 0000000..d4d09d5 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcGuard.cs @@ -0,0 +1,172 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using OpenIddict.EntityFrameworkCore.Models; +using Bifrost.Web.Features.Shared; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using static OpenIddict.Abstractions.OpenIddictConstants; +using OpenIddict.Server.AspNetCore; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// The issuer's front door, ahead of OpenIddict: the marker brake, the per-address budget, and the bound +/// on token rows. The address is the connection's, already corrected by the forwarded-headers handling that trusts only +/// the local proxy, so a caller cannot pick its own. It fails closed: anything this middleware cannot decide is a 503, never a pass. +/// +public sealed class OidcGuardMiddleware(RequestDelegate next, OidcIssuerOptions options, OidcRateLimit limit, ILogger logger) +{ + public async Task InvokeAsync(HttpContext http, IOpenIddictTokenManager tokens) + { + var endpoint = EndpointOf(http.Request.Path); + if (endpoint is null) + { + await next(http); + return; + } + + try + { + if (BrakeEngaged(options.BrakePath)) + { + await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1)); + return; + } + + if (limit.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is { } byAddress) + { + await RefuseAsync(http, StatusCodes.Status429TooManyRequests, "too_many_requests", byAddress.RetryAfter); + return; + } + + // A new authorize request parks a row for anyone who can name the (public) client; a resumed one carries request_uri and parks none. + if (endpoint == Authorize && !http.Request.Query.ContainsKey("request_uri") && await tokens.CountAsync(http.RequestAborted) >= options.Limits.MaxTokenRows) + { + await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", options.Limits.PruneInterval); + return; + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError("OIDC guard could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name); + await RefuseAsync(http, StatusCodes.Status503ServiceUnavailable, "temporarily_unavailable", TimeSpan.FromMinutes(1)); + return; + } + + await next(http); + } + + private const string Authorize = "authorize"; + + // OpenIddict answers these paths with or without a trailing slash, so the match is by segments, never exact equality. + private static string? EndpointOf(PathString path) => + path.StartsWithSegments(OidcServiceCollectionExtensions.AuthorizationPath, StringComparison.OrdinalIgnoreCase) ? Authorize + : path.StartsWithSegments(OidcServiceCollectionExtensions.TokenPath, StringComparison.OrdinalIgnoreCase) ? "token" + : path.StartsWithSegments(OidcServiceCollectionExtensions.UserInfoPath, StringComparison.OrdinalIgnoreCase) ? "userinfo" + : null; + + // A marker that cannot be told apart from an absent one (a permission or I/O error) throws, and the guard answers 503: the brake never releases by accident. + private static bool BrakeEngaged(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + File.GetAttributes(path); + return true; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return false; + } + } + + private static Task RefuseAsync(HttpContext http, int status, string error, TimeSpan retryAfter) + { + http.Response.StatusCode = status; + http.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + http.Response.ContentType = "application/json"; + return http.Response.WriteAsync($"{{\"error\":\"{error}\"}}", http.RequestAborted); + } +} + +/// +/// The issuer's per-address permits on the shared fixed-window budget. There is no per-client budget: the one client's id is +/// public and what a caller names unverified, so a budget on it would let anyone spend the real client's permits. +/// +public sealed class OidcRateLimit(OidcIssuerOptions options, TimeProvider clock) +{ + private readonly FixedWindowBudget addresses = new(options.Limits.PerAddressPermits, options.Limits.Window, options.Limits.MaxTrackedKeys, clock); + + public BudgetDenial? Spend(string address) => addresses.Spend(address); +} + +/// +/// Deletes expired and orphaned authorizations and tokens on a schedule: tokens past their expiry (parked authorize +/// requests, codes, access tokens) or no longer valid, then authorizations that are no longer valid or are ad hoc with no +/// token left. Live rows are never touched. Plain queries, not OpenIddict's ExecuteDelete prune, which the in-memory test provider +/// cannot run; an authorization takes its remaining tokens with it, because the foreign key has no cascade on Postgres. +/// +public sealed class OidcPruner(IServiceScopeFactory scopes, OidcIssuerOptions options, TimeProvider clock, ILogger logger) : BackgroundService +{ + private const int Batch = 500; + + // A fresh authorization briefly has no token yet; leave anything younger than this alone. + private static readonly TimeSpan AuthorizationGrace = TimeSpan.FromMinutes(5); + + public async Task PruneAsync(CancellationToken cancellationToken) + { + await using var scope = scopes.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = clock.GetUtcNow(); + var young = now - AuthorizationGrace; + + int removed; + do + { + var tokens = await db.Set() + .Where(token => token.ExpirationDate < now || (token.Status != Statuses.Valid && token.CreationDate < young)) + .Take(Batch).ToListAsync(cancellationToken); + db.RemoveRange(tokens); + await db.SaveChangesAsync(cancellationToken); + removed = tokens.Count; + } + while (removed == Batch); + + do + { + // A token row references its authorization (no cascade in the database), so the tokens go with it. + var authorizations = await db.Set() + .Include(authorization => authorization.Tokens) + .Where(authorization => authorization.CreationDate < young + && (authorization.Status != Statuses.Valid || (authorization.Type == AuthorizationTypes.AdHoc && !authorization.Tokens.Any()))) + .Take(Batch).ToListAsync(cancellationToken); + db.RemoveRange(authorizations.SelectMany(authorization => authorization.Tokens)); + db.RemoveRange(authorizations); + await db.SaveChangesAsync(cancellationToken); + removed = authorizations.Count; + } + while (removed == Batch); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(options.Limits.PruneInterval, clock); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + await PruneAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError("OIDC prune failed ({ExceptionType}); the next tick tries again.", exception.GetType().Name); + } + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs index ac6fa72..273acd9 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -11,8 +11,8 @@ using static OpenIddict.Abstractions.OpenIddictConstants; namespace Bifrost.Web.Features.Oidc; /// -/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid -/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed +/// 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. /// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the @@ -127,7 +127,7 @@ public sealed class OidcLogoutReconciler( { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken)) + if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null) { _backoff.Remove(subject); return; diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs index 5ad30b8..78526af 100644 --- a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -67,6 +67,9 @@ public static class OidcServiceCollectionExtensions server.Issuer = issuer; }); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); services.AddHostedService(); if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) { diff --git a/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs new file mode 100644 index 0000000..6682d14 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/ConsentEndpoints.cs @@ -0,0 +1,199 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Registration; + +/// +/// The consent page and its two form posts, for any signed-in account. The receipt rows are the only state: a receipt is +/// live while is null, and an account's messages count only while a live +/// receipt for the version in force exists. Both posts are refused by before +/// anything is read or written, since a signed-in session is a cookie and these carry no antiforgery token. +/// +public static class ConsentEndpoints +{ + public const string Path = "/auth/bifrost/consent"; + + public static void MapConsent(this IEndpointRouteBuilder app) + { + 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) + { + return Results.Redirect($"/auth/sign-in?returnUrl={Uri.EscapeDataString(LocalReturnPath.Parse(Path).Value)}"); + } + + return await PageAsync(db, actor.UserAccount.Id, null, StatusCodes.Status200OK, ct, ReturnPathOf(http.Request.Query["returnUrl"])); + }).AllowAnonymous(); + + app.MapPost(Path + "/accept", async ( + HttpContext http, + ICurrentBifrostActorAccessor actors, + BifrostDbContext db, + TimeProvider clock, + IOptions host, + CancellationToken ct) => + { + if (!RequestOriginGuard.Allows(http.Request, host.Value)) + { + return Refused(); + } + + var actor = await actors.GetAsync(ct); + if (!actor.IsAuthenticated || actor.UserAccount is null) + { + return Results.Unauthorized(); + } + + 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, ReturnPathOf(form?["returnUrl"] ?? default)); + } + + var accountId = actor.UserAccount.Id; + if (!await HasLiveReceiptAsync(db, accountId, ct)) + { + db.ConsentReceipts.Add(new ConsentReceipt + { + UserAccountId = accountId, + NoticeVersion = TrainingNotice.Version, + AcceptedAtUtc = clock.GetUtcNow() + }); + try + { + await db.SaveChangesAsync(ct); + } + catch (DbUpdateException error) when (IsUniqueViolation(error)) + { + // The filtered unique index refused a second live receipt: another request holds it, so this accept has nothing to add. + // Only that violation is swallowed; any other store fault propagates. Not re-checked: a withdraw may already have stamped the winner, and the outcome for this request is the same. + db.ChangeTracker.Clear(); + } + } + + return new SeeOther(ReturnPathOf(form["returnUrl"]) ?? Path); + }).AllowAnonymous(); + + app.MapPost(Path + "/withdraw", async ( + HttpContext http, + ICurrentBifrostActorAccessor actors, + BifrostDbContext db, + TimeProvider clock, + IOptions host, + CancellationToken ct) => + { + if (!RequestOriginGuard.Allows(http.Request, host.Value)) + { + return Refused(); + } + + var actor = await actors.GetAsync(ct); + if (!actor.IsAuthenticated || actor.UserAccount is null) + { + return Results.Unauthorized(); + } + + var live = await db.ConsentReceipts + .Where(x => x.UserAccountId == actor.UserAccount.Id && x.WithdrawnAtUtc == null) + .ToListAsync(ct); + if (live.Count > 0) + { + var now = clock.GetUtcNow(); + foreach (var receipt in live) + { + receipt.WithdrawnAtUtc = now; + } + + await db.SaveChangesAsync(ct); + } + + return Back(); + }).AllowAnonymous(); + } + + // Postgres SqlState 23505, on the exception or anywhere down its inner chain. + private static bool IsUniqueViolation(Exception error) + { + for (var e = (Exception?)error; e is not null; e = e.InnerException) + { + if (e is Npgsql.PostgresException { SqlState: Npgsql.PostgresErrorCodes.UniqueViolation }) + { + return true; + } + } + + return false; + } + + 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); + + /// 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) + { + const int MaxFormBytes = 1024; + if (!request.HasFormContentType || request.ContentLength > MaxFormBytes) + { + return null; + } + + if (request.HttpContext.Features.Get() is { IsReadOnly: false } limit) + { + limit.MaxRequestBodySize = MaxFormBytes; + } + + try + { + return await request.ReadFormAsync(ct); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } + } + + private sealed class SeeOther(string location) : IResult + { + public Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.StatusCode = StatusCodes.Status303SeeOther; + httpContext.Response.Headers.Location = location; + httpContext.Response.Headers.CacheControl = "no-store"; + return Task.CompletedTask; + } + } + + 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, returnPath), status); + } + + private sealed class HtmlPage(string html, int status) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.StatusCode = status; + httpContext.Response.ContentType = "text/html; charset=utf-8"; + httpContext.Response.Headers.CacheControl = "no-store"; + await httpContext.Response.WriteAsync(html); + } + } +} diff --git a/src/Bifrost.Web/Features/Registration/ConsentPage.cs b/src/Bifrost.Web/Features/Registration/ConsentPage.cs new file mode 100644 index 0000000..f34719f --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/ConsentPage.cs @@ -0,0 +1,105 @@ +using System.Net; +using Bifrost.Web.Domain; + +namespace Bifrost.Web.Features.Registration; + +/// Where an account stands against the notice in force. Exactly one holds. +public abstract record ConsentState +{ + public sealed record None : ConsentState; + + public sealed record Current(DateTimeOffset AgreedAtUtc) : ConsentState; + + public sealed record Older(int Version, DateTimeOffset AgreedAtUtc) : ConsentState; + + public sealed record Withdrawn(DateTimeOffset WithdrawnAtUtc) : ConsentState; +} + +/// +/// The server-rendered consent page: the notice in force, the account's state and the buttons that apply. No script, +/// no external asset, no cookie of its own. +/// +public static class ConsentPage +{ + /// A live receipt for the version in force wins, then a live older one, then the latest withdrawal, else none. + public static ConsentState StateOf(IReadOnlyCollection receipts) + { + var live = receipts.Where(x => x.WithdrawnAtUtc is null).ToList(); + if (live.FirstOrDefault(x => x.NoticeVersion == TrainingNotice.Version) is { } current) + { + return new ConsentState.Current(current.AcceptedAtUtc); + } + + if (live.OrderByDescending(x => x.NoticeVersion).FirstOrDefault() is { } older) + { + return new ConsentState.Older(older.NoticeVersion, older.AcceptedAtUtc); + } + + return receipts.Count > 0 + ? new ConsentState.Withdrawn(receipts.Max(x => x.WithdrawnAtUtc!.Value)) + : new ConsentState.None(); + } + + 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, 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)}. 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 $$""" + + + + + + Training consent - Bifrost + + + +
+ Your account +

Training consent

+ {{summary}} +

{{E(message)}}

+
+ Training notice, version {{version}} + {{TrainingNotice.Html("notice")}} +
+ {{button}} +
+ + + """; + } +} diff --git a/src/Bifrost.Web/Features/Registration/NativeRegistration.cs b/src/Bifrost.Web/Features/Registration/NativeRegistration.cs new file mode 100644 index 0000000..042b615 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/NativeRegistration.cs @@ -0,0 +1,123 @@ +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Membership; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Bifrost.Web.Features.Registration; + +/// What a client sent to register. claims the time-lock path instead of a proof of work. +public sealed record RegistrationAttempt( + string? Identity, + string? DisplayName, + string? Challenge, + string? Nonce, + bool Waited, + int? AcceptedNoticeVersion); + +/// Which input a refusal is about, so the form can mark it invalid. +public enum RegistrationField { None, Identity, Consent } + +/// +/// Either a signed-in , or a to return as is (the rate limits), or a rejection +/// with a status and message. is set when the challenge was not spent and a retry needs only a correction, so the form should carry it on (the time-lock clock must not restart). +/// +public sealed record RegistrationOutcome( + UserAccount? Account, + IResult? Refusal, + int Status, + string? Message, + RegistrationField Field, + bool KeepChallenge) +{ + public static RegistrationOutcome Reject(int status, string message, RegistrationField field = RegistrationField.None, bool keepChallenge = false) => + new(null, null, status, message, field, keepChallenge); +} + +/// +/// The one registration gate, run by the JSON endpoint and the form alike after each has spent the general address budget. +/// Order: consent version (when sent), one proof only, the identity rules (which spend nothing), the challenge (proof of work, or the +/// time-lock plus its tighter budget), then the account (and its receipt when consent was sent) in one save, then the cookie. A refusal at any step +/// creates nothing, and a refusal before the challenge step spends neither the challenge nor a fallback permit. +/// +public sealed class NativeRegistration( + BifrostIdentityService identities, + RegistrationChallengeService challenges, + RegistrationRateLimit rateLimit) +{ + public async Task RunAsync(HttpContext http, RegistrationAttempt attempt, CancellationToken cancellationToken) + { + // Consent is optional: none sends no receipt. A version that is sent must be the one in force; a refusal here creates nothing. + if (attempt.AcceptedNoticeVersion is { } sent && sent != TrainingNotice.Version) + { + return RegistrationOutcome.Reject( + StatusCodes.Status400BadRequest, + $"The training notice in force is version {TrainingNotice.Version}; send that version to agree, or send none to register without agreeing.", + RegistrationField.Consent); + } + + var hasNonce = !string.IsNullOrEmpty(attempt.Nonce); + if (hasNonce && attempt.Waited) + { + return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Send a nonce or waited, not both."); + } + + try + { + await identities.CheckAvailableAsync(attempt.Identity ?? "", cancellationToken); + } + catch (BifrostIdentityException error) + { + return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity, keepChallenge: true); + } + + var failed = RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, "Registration challenge failed."); + if (hasNonce) + { + if (!challenges.TryConsume(attempt.Challenge, attempt.Nonce)) + { + return failed; + } + } + else if (attempt.Waited) + { + if (challenges.WaitRemaining(attempt.Challenge) is not { } remaining) + { + return failed; + } + + if (remaining > TimeSpan.Zero) + { + var seconds = (int)Math.Ceiling(remaining.TotalSeconds); + return RegistrationOutcome.Reject( + StatusCodes.Status400BadRequest, + $"Too early: {seconds} more second{(seconds == 1 ? "" : "s")} to wait after the page opened. Your challenge is still good; submit again then.", + keepChallenge: true); + } + + if (rateLimit.RefuseFallback(http) is { } fallbackRefused) + { + return new(null, fallbackRefused, 0, null, RegistrationField.None, false); + } + + if (!challenges.TryConsumeWaited(attempt.Challenge)) + { + return failed; + } + } + else + { + return failed; + } + + try + { + var account = await identities.RegisterNativeAsync(attempt.Identity ?? "", attempt.DisplayName, attempt.AcceptedNoticeVersion, cancellationToken); + await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, identities.BuildPrincipal(account)); + return new(account, null, StatusCodes.Status200OK, null, RegistrationField.None, false); + } + catch (BifrostIdentityException error) + { + return RegistrationOutcome.Reject(StatusCodes.Status400BadRequest, error.Message, RegistrationField.Identity); + } + } +} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs new file mode 100644 index 0000000..764810d --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/RegistrationChallengeService.cs @@ -0,0 +1,180 @@ +using System.Security.Cryptography; +using System.Text; +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Registration; + +public sealed record RegistrationChallenge(string Challenge, int Difficulty, DateTimeOffset ExpiresAt); + +/// +/// Self-hosted proof-of-work challenge for the anonymous registration. A challenge is a data-protected +/// (random id, expiry) payload, so the server keeps nothing per issue; only consumed ids are remembered, in a bounded +/// set whose entries are dropped at expiry. A full set refuses both issuing and consuming rather than grow. +/// +public sealed class RegistrationChallengeService( + IDataProtectionProvider dataProtection, + IOptions options, + TimeProvider clock) +{ + public const string Purpose = "Bifrost.Registration.Challenge.v1"; + + public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5); + + public const int MaxConsumed = 10_000; + + private const int MaxNonceLength = 20; + + private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose); + private readonly HashSet consumed = new(StringComparer.Ordinal); + // Ordered by expiry: a challenge consumed late has an earlier expiry than one consumed before it, so a plain FIFO would strand it. + private readonly PriorityQueue expiries = new(); + private readonly object gate = new(); + + private int Difficulty => options.Value.Captcha.Difficulty; + + /// Null when the consumed set is full: refuse rather than grow. + public RegistrationChallenge? Issue() + { + var now = clock.GetUtcNow(); + lock (gate) + { + Purge(now); + if (consumed.Count >= MaxConsumed) + { + return null; + } + } + + var expiresAt = now + Lifetime; + var difficulty = Difficulty; + var id = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + var challenge = protector.Protect($"{id}.{expiresAt.ToUnixTimeMilliseconds()}.{difficulty}.{now.ToUnixTimeMilliseconds()}"); + return new RegistrationChallenge(challenge, difficulty, expiresAt); + } + + /// Proof-of-work path: the nonce must meet the difficulty the challenge was issued with. Spends the challenge. + public bool TryConsume(string? challenge, string? nonce) + { + if (!IsNonce(nonce) || Open(challenge) is not { } open) + { + return false; + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(challenge + ":" + nonce)); + return LeadingZeroBits(hash) >= open.Difficulty && Spend(open); + } + + /// + /// Time-lock path, step one: how long until a waited submission of this challenge is accepted. Null when the + /// token is not a live challenge of ours; zero when the wait is over. Spends nothing. + /// + public TimeSpan? WaitRemaining(string? challenge) + { + if (Open(challenge) is not { } open) + { + return null; + } + + var remaining = open.IssuedAt + TimeSpan.FromSeconds(options.Value.Captcha.FallbackWaitSeconds) - clock.GetUtcNow(); + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + /// Time-lock path, step two: spends the challenge if its wait is over. No proof of work is checked. + public bool TryConsumeWaited(string? challenge) => + WaitRemaining(challenge) == TimeSpan.Zero && Open(challenge) is { } open && Spend(open); + + /// True when the token is ours, unexpired and not yet spent, so a form may carry it into its next attempt. + public bool IsLive(string? challenge) + { + if (Open(challenge) is not { } open) + { + return false; + } + + lock (gate) + { + return !consumed.Contains(open.Id); + } + } + + private sealed record OpenChallenge(string Id, int Difficulty, DateTimeOffset ExpiresAt, DateTimeOffset IssuedAt); + + /// The payload of a challenge that we protected and that has not expired; null otherwise. + private OpenChallenge? Open(string? challenge) + { + if (string.IsNullOrEmpty(challenge)) + { + return null; + } + + OpenChallenge open; + try + { + var payload = protector.Unprotect(challenge).Split('.'); + if (payload.Length != 4) + { + return null; + } + + var culture = System.Globalization.CultureInfo.InvariantCulture; + open = new OpenChallenge( + payload[0], + int.Parse(payload[2], culture), + DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[1], culture)), + DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(payload[3], culture))); + } + catch (Exception) + { + return null; + } + + return clock.GetUtcNow() >= open.ExpiresAt ? null : open; + } + + private bool Spend(OpenChallenge open) + { + var now = clock.GetUtcNow(); + lock (gate) + { + Purge(now); + if (consumed.Count >= MaxConsumed || !consumed.Add(open.Id)) + { + return false; + } + + expiries.Enqueue(open.Id, open.ExpiresAt); + return true; + } + } + + private void Purge(DateTimeOffset now) + { + while (expiries.TryPeek(out var id, out var expiresAt) && expiresAt <= now) + { + expiries.Dequeue(); + consumed.Remove(id); + } + } + + private static bool IsNonce(string? nonce) => + nonce is { Length: >= 1 and <= MaxNonceLength } && nonce.All(c => c is >= '0' and <= '9'); + + public static int LeadingZeroBits(ReadOnlySpan hash) + { + var bits = 0; + foreach (var b in hash) + { + if (b == 0) + { + bits += 8; + continue; + } + + return bits + System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24; + } + + return bits; + } +} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationPage.cs b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs new file mode 100644 index 0000000..ef0e232 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/RegistrationPage.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Text; + +namespace Bifrost.Web.Features.Registration; + +/// What the sign-up page shows. Everything user-supplied is HTML-encoded on the way out. +public sealed record RegistrationPageModel( + string ReturnPath, + string Challenge, + int Difficulty, + int WaitSeconds, + string Identity = "", + string DisplayName = "", + string? Error = null, + RegistrationField ErrorField = RegistrationField.None); + +/// +/// The server-rendered sign-up form. It works with no script (time-lock path); the inline script, when it can, replaces the +/// wait by solving the proof of work. No external asset, no cookie, no third party. +/// +public static class RegistrationPage +{ + // Hash rule is RegistrationChallengeService's: SHA-256 of challenge + ":" + nonce, leading zero bits >= difficulty. + // Hashes run in batches that yield to the UI between them. If the browser has no crypto.subtle, nothing here runs + // and the form submits on the time-lock path. + public const string Script = """ + (function () { + var form = document.getElementById("register"); + var status = document.getElementById("status"); + if (!form || !status || !window.crypto || !window.crypto.subtle || !window.TextEncoder) { return; } + var BATCH = 256; + var busy = false; + var solved = false; + var skipped = false; + + function zeroBits(bytes) { + var bits = 0; + for (var i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) { bits += 8; continue; } + return bits + Math.clz32(bytes[i]) - 24; + } + return bits; + } + + async function solve(challenge, difficulty) { + var encoder = new TextEncoder(); + for (var start = 0; !skipped; start += BATCH) { + var tries = []; + for (var n = start; n < start + BATCH; n++) { + tries.push(crypto.subtle.digest("SHA-256", encoder.encode(challenge + ":" + n))); + } + var hashes = await Promise.all(tries); + for (var i = 0; i < hashes.length; i++) { + if (zeroBits(new Uint8Array(hashes[i])) >= difficulty) { return String(start + i); } + } + await new Promise(function (resolve) { setTimeout(resolve, 0); }); + } + return null; + } + + form.addEventListener("submit", function (event) { + if (event.submitter && event.submitter.id === "skip") { + // The way out of a slow solve: stop it and let this submit go on the time-lock path. + skipped = true; + solved = true; + form.elements.nonce.value = ""; + form.elements.waited.disabled = false; + status.textContent = "Skipping the automatic check."; + return; + } + if (solved) { return; } + event.preventDefault(); + if (busy) { return; } + busy = true; + status.textContent = "Checking. This can take longer on a slow device; press Skip the automatic check to wait " + form.getAttribute("data-wait") + " seconds instead."; + solve(form.elements.challenge.value, parseInt(form.getAttribute("data-difficulty"), 10)).then(function (nonce) { + if (skipped || nonce === null) { return; } + form.elements.nonce.value = nonce; + form.elements.waited.disabled = true; + solved = true; + status.textContent = "Creating your account"; + form.submit(); + }, function () { + solved = true; + busy = false; + status.textContent = "The automatic check did not run in this browser. Press Skip the automatic check and wait as described below."; + }); + }); + })(); + """; + + public static string Render(RegistrationPageModel model) + { + static string E(string? text) => WebUtility.HtmlEncode(text ?? ""); + var notice = TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); + var summary = new StringBuilder(); + if (model.Error is not null) + { + var link = model.ErrorField switch + { + RegistrationField.Identity => """
""", + RegistrationField.Consent => """""", + _ => "" + }; + summary.Append($""""""); + } + + string Invalid(RegistrationField field) => model.Error is not null && model.ErrorField == field ? " aria-invalid=\"true\" aria-describedby=\"errors\"" : ""; + var wait = model.WaitSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return $$""" + + + + + + Create an account - Bifrost + + + +
+ Public registration +

Create a Bifrost account

+

A new account can chat. It has no vote and opens no member or governance surface.

+ {{summary}} +
+
+ Your account + + + + +
+
+ Training notice, version {{notice}} + {{TrainingNotice.Html("notice")}} + +

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.

+ +
+ + + + +

+ + +
+

Without a script, or if the automatic check is slow, wait {{wait}} seconds after the page opened and press the button to skip it (or Create account when there is no script). Pressed sooner, the page comes back saying how many seconds remain and keeps your names; the consent box comes back unchecked, so tick it again if you want to agree. Without the automatic check only a few registrations per hour are accepted from one address.

+

After registering, Bifrost returns you to {{E(model.ReturnPath)}}.

+
+ + + + """; + } +} diff --git a/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs b/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs new file mode 100644 index 0000000..d95e7f3 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/RegistrationRateLimit.cs @@ -0,0 +1,56 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Shared; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Features.Registration; + +/// +/// The per-address permits on the registration endpoints (challenge and register share one budget), on the shared +/// fixed-window budget. It exists so one address cannot fill the consumed-challenge set alone. It fails closed: +/// anything it cannot decide is a 503, never a pass. +/// +public sealed class RegistrationRateLimit(IOptions options, TimeProvider clock, ILogger logger) +{ + private readonly FixedWindowBudget addresses = new( + options.Value.Limits.PerAddressPermits, + options.Value.Limits.Window, + options.Value.Limits.MaxTrackedKeys, + clock); + + // Time-lock submissions cost the client no work, so they get their own, much tighter budget per address. + private readonly FixedWindowBudget fallbacks = new( + options.Value.Limits.FallbackPermits, + options.Value.Limits.FallbackWindow, + options.Value.Limits.MaxTrackedKeys, + clock); + + /// Null when the request may proceed; otherwise the refusal to return. + public IResult? Refuse(HttpContext http) => Refuse(http, addresses, "Too many registration requests from this address."); + + /// The stricter budget for a submission that passed the time-lock instead of the proof of work. + public IResult? RefuseFallback(HttpContext http) => + Refuse(http, fallbacks, "Too many registrations without the quick check from this address; try again later or let the page run its script."); + + private IResult? Refuse(HttpContext http, FixedWindowBudget budget, string message) + { + try + { + if (budget.Spend(ClientAddress.BudgetKey(http.Connection.RemoteIpAddress)) is not { } denial) + { + return null; + } + + http.Response.Headers.RetryAfter = RetryAfter(denial.RetryAfter); + return Results.Text(message, statusCode: StatusCodes.Status429TooManyRequests); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError("Registration rate limit could not decide ({ExceptionType}); the request is refused.", exception.GetType().Name); + http.Response.Headers.RetryAfter = "60"; + return Results.Text("Registration is unavailable; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable); + } + } + + private static string RetryAfter(TimeSpan wait) => + Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/src/Bifrost.Web/Features/Registration/TrainingNotice.cs b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs new file mode 100644 index 0000000..a823426 --- /dev/null +++ b/src/Bifrost.Web/Features/Registration/TrainingNotice.cs @@ -0,0 +1,36 @@ +using System.Net; +using System.Text; + +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 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 = 1; + + public const string Text = + "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. 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, 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) + { + var html = new StringBuilder($"
"); + foreach (var paragraph in Text.Split("\n\n")) + { + html.Append("

").Append(WebUtility.HtmlEncode(paragraph)).Append("

"); + } + + return html.Append("
").ToString(); + } +} diff --git a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs index 1a3cd0f..04879e2 100644 --- a/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs +++ b/src/Bifrost.Web/Features/Shared/ApplicationBootstrapper.cs @@ -10,6 +10,7 @@ public sealed class ApplicationBootstrapper( BifrostDbContext dbContext, IOptions bootstrapOptions, TimeProvider timeProvider, + AuditTrailService auditTrailService, ILogger logger) { public async Task RunAsync(CancellationToken cancellationToken) @@ -40,16 +41,11 @@ public sealed class ApplicationBootstrapper( foreach (var adminUser in adminUsers.Where(x => x.Membership is not null)) { - var membership = adminUser.Membership!; - membership.Status = MembershipStatus.Active; - membership.IsPlatformAdmin = true; - membership.CanManageProjects = true; - membership.CanManageLedger = true; - membership.CanModerateMotions = true; - membership.ApprovedAtUtc ??= now; - - EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin"); - EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role"); + var before = adminUser.Membership!.Status; + if (TryPromoteBootstrapAdmin(adminUser, now, logger) && before != adminUser.Membership.Status) + { + await RecordPromotionAsync(auditTrailService, adminUser, before, cancellationToken); + } } if (dbContext.ChangeTracker.HasChanges()) @@ -58,6 +54,41 @@ public sealed class ApplicationBootstrapper( } } + /// + /// The one bootstrap-admin promotion, used at startup and at sign-in. A Suspended account is left exactly as it is (an admin's + /// decision; only the suspension service lifts it) and the refusal is logged by account id. Returns whether it promoted. + /// + public static bool TryPromoteBootstrapAdmin(UserAccount account, DateTimeOffset now, ILogger logger) + { + var membership = account.Membership!; + if (membership.Status == MembershipStatus.Suspended) + { + logger.LogWarning("Bootstrap admin promotion skipped for suspended account {UserAccountId}.", account.Id); + return false; + } + + membership.Status = MembershipStatus.Active; + membership.IsPlatformAdmin = true; + membership.CanManageProjects = true; + membership.CanManageLedger = true; + membership.CanModerateMotions = true; + membership.ApprovedAtUtc ??= now; + + EnsureRole(membership, MemberRole.PlatformAdmin, null, now, "Bootstrap admin"); + EnsureRole(membership, MemberRole.StandardMember, null, now, "Default active member role"); + return true; + } + + /// The receipt for a bootstrap promotion that changed membership status; saves together with the promotion. + public static Task RecordPromotionAsync(AuditTrailService auditTrailService, UserAccount account, MembershipStatus before, CancellationToken cancellationToken) => + auditTrailService.RecordAsync( + null, + nameof(Bifrost.Web.Domain.Membership), + account.Membership!.Id, + "membership.bootstrap-promoted", + $"Bootstrap admin promotion of {account.DisplayName} ({before} -> {account.Membership.Status}).", + cancellationToken); + public static void EnsureRole( Bifrost.Web.Domain.Membership membership, MemberRole role, diff --git a/src/Bifrost.Web/Features/Shared/ClientAddress.cs b/src/Bifrost.Web/Features/Shared/ClientAddress.cs new file mode 100644 index 0000000..f959e24 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/ClientAddress.cs @@ -0,0 +1,32 @@ +using System.Net; + +namespace Bifrost.Web.Features.Shared; + +public static class ClientAddress +{ + /// + /// The partition key for a per-address budget: the connection's address, already corrected by the forwarded-headers + /// handling that trusts only the local proxy. An IPv6 caller owns its /64, so rotating the low bits buys nothing. + /// + public static string BudgetKey(IPAddress? address) + { + if (address is null) + { + return "unknown"; + } + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6) + { + return address.ToString(); + } + + var bytes = address.GetAddressBytes(); + Array.Clear(bytes, 8, 8); + return new IPAddress(bytes).ToString() + "/64"; + } +} diff --git a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs index 3a31269..0837f7e 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -92,6 +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") && 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/RequestOriginGuard.cs b/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs new file mode 100644 index 0000000..be1f701 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/RequestOriginGuard.cs @@ -0,0 +1,41 @@ +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.Http; + +namespace Bifrost.Web.Features.Shared; + +// The one place that decides whether a state-changing request to an anonymous form (the vote page, the sign-up form) came from +// the site itself. These flows carry no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead +// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser +// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header +// are all refused. Header values are compared, never echoed. +public static class RequestOriginGuard +{ + public static bool Allows(HttpRequest request, BifrostHostOptions host) + { + if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site)) + { + return false; + } + + var origin = request.Headers.Origin; + if (request.Headers.ContainsKey("Origin")) + { + return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal); + } + + var fetchSite = request.Headers["Sec-Fetch-Site"]; + return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal); + } + + /// + /// For the no-script sign-up form only. Text browsers (Lynx, w3m, Links) send neither Origin nor Sec-Fetch-Site, so a + /// request with neither is let through when it carries no Bifrost session cookie: there is no session to replace. + /// Any Origin or Sec-Fetch-Site present is judged exactly as judges it, and a headerless request + /// that does carry a session cookie is refused. + /// + public static bool AllowsFormWithoutFetchMetadata(HttpRequest request, BifrostHostOptions host) => + Allows(request, host) || + (!request.Headers.ContainsKey("Origin") && + !request.Headers.ContainsKey("Sec-Fetch-Site") && + !request.Cookies.Keys.Any(name => name.StartsWith(BifrostCookies.Session, StringComparison.Ordinal))); +} diff --git a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs index 98b8dc7..58fe2fb 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,5 +1,6 @@ using Bifrost.Web.Configuration; using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Registration; using Microsoft.Extensions.Options; namespace Bifrost.Web.Features.Shared; @@ -12,12 +13,18 @@ public sealed class StartupConfigurationValidator( IOptions heimdallOptions, IOptions stripeOptions, IOptions hostOptions, - IOptions conceptVoteOptions) + IOptions conceptVoteOptions, + IOptions deskOptions, + 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)) @@ -45,6 +52,32 @@ public sealed class StartupConfigurationValidator( failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1."); } + ValidateDesk(deskOptions.Value, failures); + + var difficulty = registrationOptions.Value.Captcha.Difficulty; + if (difficulty < RegistrationOptions.MinDifficulty || difficulty > RegistrationOptions.MaxDifficulty) + { + failures.Add($"Bifrost:Registration:Captcha:Difficulty must be between {RegistrationOptions.MinDifficulty} and {RegistrationOptions.MaxDifficulty}."); + } + + var wait = registrationOptions.Value.Captcha.FallbackWaitSeconds; + if (wait < RegistrationOptions.MinFallbackWaitSeconds || wait > RegistrationOptions.MaxFallbackWaitSeconds || + wait >= RegistrationChallengeService.Lifetime.TotalSeconds) + { + failures.Add($"Bifrost:Registration:Captcha:FallbackWaitSeconds must be between {RegistrationOptions.MinFallbackWaitSeconds} and {RegistrationOptions.MaxFallbackWaitSeconds} and shorter than the challenge lifetime ({RegistrationChallengeService.Lifetime.TotalSeconds:0} seconds)."); + } + + var limits = registrationOptions.Value.Limits; + if (limits.PerAddressPermits < 1 || limits.Window <= TimeSpan.Zero || limits.MaxTrackedKeys < 1) + { + failures.Add("Bifrost:Registration:Limits:PerAddressPermits and MaxTrackedKeys must be at least 1 and Window must be positive."); + } + + if (limits.FallbackPermits < 1 || limits.FallbackWindow <= TimeSpan.Zero) + { + failures.Add("Bifrost:Registration:Limits:FallbackPermits must be at least 1 and FallbackWindow must be positive."); + } + if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) { Refuse(failures); @@ -72,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) @@ -116,6 +156,112 @@ public sealed class StartupConfigurationValidator( Refuse(failures); } + // Checked in every environment: a bad Mind list or decider id would otherwise pass startup and misroute or + // 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()) + { + failures.Add("Desk:Minds must list at least one Mind when the Desk section exists."); + } + + return; + } + + if (!Uri.TryCreate(desk.AllowedOrigin, UriKind.Absolute, out var origin) || + origin.Scheme is not ("http" or "https") || + !string.Equals(desk.AllowedOrigin, origin.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal)) + { + failures.Add("Desk:AllowedOrigin must be one http(s) origin: scheme, host and optional port, no path."); + } + + if (desk.MaxBodyBytes < 1) + { + failures.Add("Desk:MaxBodyBytes must be at least 1."); + } + + if (desk.RetryAfterSeconds < 1) + { + failures.Add("Desk:RetryAfterSeconds must be at least 1."); + } + + var ids = new HashSet(StringComparer.Ordinal); + var sockets = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < desk.Minds.Count; i++) + { + var mind = desk.Minds[i]; + if (string.IsNullOrWhiteSpace(mind.Id)) + { + failures.Add($"Desk:Minds:{i}:Id must not be blank."); + } + else if (!ids.Add(mind.Id)) + { + failures.Add($"Desk:Minds:{i}:Id repeats an earlier Mind id."); + } + + if (!Path.IsPathFullyQualified(mind.Socket)) + { + failures.Add($"Desk:Minds:{i}:Socket must be an absolute path."); + } + else if (!sockets.Add(mind.Socket)) + { + failures.Add($"Desk:Minds:{i}:Socket repeats an earlier Mind's socket."); + } + + if (mind.Deciders.Count is < 1 or > DeskMindOptions.MaxDeciders) + { + failures.Add($"Desk:Minds:{i}:Deciders must hold 1 to {DeskMindOptions.MaxDeciders} account ids."); + } + + var deciders = new HashSet(StringComparer.Ordinal); + foreach (var decider in mind.Deciders) + { + if (!IsLowercaseGuid(decider)) + { + failures.Add($"Desk:Minds:{i}:Deciders entries must be lowercase D-format account ids."); + } + else if (!deciders.Add(decider)) + { + failures.Add($"Desk:Minds:{i}:Deciders repeats an account id."); + } + } + } + + var attribution = desk.Attribution; + if (!string.IsNullOrEmpty(attribution.Operator) && !IsLowercaseGuid(attribution.Operator)) + { + failures.Add("Desk:Attribution:Operator must be a lowercase D-format account id."); + } + + var stewards = new HashSet(StringComparer.Ordinal); + foreach (var steward in attribution.Stewards) + { + if (!IsLowercaseGuid(steward)) + { + failures.Add("Desk:Attribution:Stewards entries must be lowercase D-format account ids."); + } + else if (!stewards.Add(steward)) + { + failures.Add("Desk:Attribution:Stewards repeats an account id."); + } + else if (string.Equals(steward, attribution.Operator, StringComparison.Ordinal)) + { + failures.Add("Desk:Attribution:Operator must not also be a steward."); + } + } + } + + private static bool IsLowercaseGuid(string? value) => + Guid.TryParseExact(value, "D", out var guid) && string.Equals(value, guid.ToString("D"), StringComparison.Ordinal); + private static void Refuse(List failures) { if (failures.Count > 0) 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 0d9d546..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) {
@@ -176,7 +181,7 @@
- @if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active) + @if (Model.Actor.CanManageMembership && user.Status != MembershipStatus.Active && user.Status != MembershipStatus.Suspended) { @@ -184,6 +189,19 @@ } + @if (Model.Actor.CanManageMembership) + { + @if (user.Status == MembershipStatus.Suspended) + { +
@(string.IsNullOrWhiteSpace(user.SuspensionReason) ? Bifrost.Web.Pages.App.Members.IndexModel.LegacySuspensionText : "Suspended: " + user.SuspensionReason)
+ } +
+ + + +
+ } + @if (Model.Actor.CanManageMembership) {
diff --git a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs index 2f385b7..a4bb901 100644 --- a/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs +++ b/src/Bifrost.Web/Pages/App/Members/Index.cshtml.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; 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; @@ -12,6 +13,7 @@ public sealed class IndexModel( BifrostDbContext dbContext, ICurrentBifrostActorAccessor actorAccessor, AuditTrailService auditTrailService, + MembershipSuspensionService suspension, TimeProvider timeProvider) : PageModel { [BindProperty] @@ -20,6 +22,25 @@ 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 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; } = []; @@ -100,9 +121,22 @@ 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); - user.Membership!.Status = MembershipStatus.Active; + // The chat tier holds no membership by design (docs/oidc-issuer.md); approval refuses rather than inventing one. + if (user is null || user.Membership is null) + { + Notice = user is null ? RefusalNotices[SuspensionOutcome.NoSuchAccount] : NoMembershipNotice; + return RedirectToPage(); + } + + if (user.Membership.Status == MembershipStatus.Suspended) + { + Notice = ApproveSuspendedNotice; // only Unsuspend lifts a suspension + return RedirectToPage(); + } + + user.Membership.Status = MembershipStatus.Active; user.Membership.ApprovedAtUtc = timeProvider.GetUtcNow(); ApplicationBootstrapper.EnsureRole( user.Membership, @@ -124,6 +158,27 @@ public sealed class IndexModel( return RedirectToPage(); } + public async Task OnPostSuspendAsync(Guid userAccountId, string? reason, CancellationToken cancellationToken) => + await ChangeSuspensionAsync(userAccountId, reason, suspend: true, cancellationToken); + + public async Task OnPostUnsuspendAsync(Guid userAccountId, string? reason, CancellationToken cancellationToken) => + await ChangeSuspensionAsync(userAccountId, reason, suspend: false, cancellationToken); + + private async Task ChangeSuspensionAsync(Guid userAccountId, string? reason, bool suspend, CancellationToken cancellationToken) + { + Actor = await actorAccessor.GetAsync(cancellationToken); + var outcome = suspend + ? await suspension.SuspendAsync(Actor, userAccountId, reason, cancellationToken) + : await suspension.UnsuspendAsync(Actor, userAccountId, reason, cancellationToken); + if (outcome == SuspensionOutcome.NotAdmin) + { + return Forbid(); + } + + Notice = RefusalNotices.GetValueOrDefault(outcome); + return RedirectToPage(); + } + public async Task OnPostToggleRoleAsync(Guid userAccountId, MemberRole role, CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); @@ -135,10 +190,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(); } @@ -153,8 +227,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); } @@ -198,7 +274,13 @@ public sealed class IndexModel( var user = await dbContext.UserAccounts .Include(x => x.Membership!) .ThenInclude(x => x.TierSnapshots) - .SingleAsync(x => x.Id == userAccountId, cancellationToken); + .SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + + if (user is null) + { + Notice = RefusalNotices[SuspensionOutcome.NoSuchAccount]; + return RedirectToPage(); + } if (user.Membership is null) { @@ -317,6 +399,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, @@ -404,6 +487,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/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/Status.cshtml b/src/Bifrost.Web/Pages/Membership/Status.cshtml index 2c1ab8f..8f683a4 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml @@ -24,6 +24,19 @@

@Model.StatusMessage

+ @if (Model.LinkMessage is not null) + { +
@Model.LinkMessage
+ } + + @if (Model.CanLinkGoogle) + { +
+ + You will sign in to Heimdall again, then to Google. Google links your account; it does not sign you in. +
+ } + @if (Model.Invitation is not null) {
diff --git a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs index d579dd3..13b47ca 100644 --- a/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs +++ b/src/Bifrost.Web/Pages/Membership/Status.cshtml.cs @@ -1,17 +1,62 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; +using System.Security.Claims; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace Bifrost.Web.Pages.Membership; [Authorize] public sealed class StatusModel( ICurrentBifrostActorAccessor actorAccessor, - BifrostDbContext dbContext) : PageModel + BifrostDbContext dbContext, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger) : PageModel { + /// The wording of the link outcome the page was sent back with: a closed set, null for anything else. + public string? LinkMessage { get; private set; } + + /// Whether this session belongs to a Heimdall account a Google login can be attached to. + public bool CanLinkGoogle => User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId) is { Length: > 0 }; + + /// + /// Begins an explicit Google link: an ordinary Heimdall sign-in for the account already signed in here, kept as + /// an attempt that is only the proof for the link. POST with antiforgery only; nothing is started without a session. + /// + public async Task OnPostLinkGoogleAsync(CancellationToken cancellationToken) + { + var accountId = User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId); + if (string.IsNullOrEmpty(accountId)) + { + return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + + try + { + var start = await heimdallAuthService.StartLinkAsync( + accountId, + User.FindFirstValue(BifrostClaimTypes.AuthProvider), + hostOptions.Value.PublicBaseUrl, + Request.Cookies, + cancellationToken); + Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); + return Redirect(start.AuthorizationUrl.ToString()); + } + catch (HeimdallAuthException error) + { + logger.LogWarning("Heimdall link start refused: {Reason}", error.Message); + return RedirectToPage(null, new { link = HeimdallLinkOutcomes.Code(HeimdallLinkOutcome.Error) }); + } + } + public CurrentBifrostActor Actor { get; private set; } = CurrentBifrostActor.Anonymous; public MembershipInvitation? Invitation { get; private set; } @@ -45,6 +90,7 @@ public sealed class StatusModel( public async Task OnGetAsync(CancellationToken cancellationToken) { Actor = await actorAccessor.GetAsync(cancellationToken); + LinkMessage = HeimdallLinkOutcomes.Wording(Request.Query["link"].ToString()); if (Actor.UserAccount is null) { diff --git a/src/Bifrost.Web/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/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs index dd87f6d..41ed934 100644 --- a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -29,7 +29,7 @@ public sealed class VoteModel( // The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler, // any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own - // origin (CastRequestGuard); no antiforgery cookie or token exists on this flow. + // origin (RequestOriginGuard); no antiforgery cookie or token exists on this flow. public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) { var method = context.HttpContext.Request.Method; @@ -50,7 +50,7 @@ public sealed class VoteModel( return; } - if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value)) + if (HttpMethods.IsPost(method) && !RequestOriginGuard.Allows(context.HttpContext.Request, host.Value)) { context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); return; diff --git a/src/Bifrost.Web/Pages/Shared/_Layout.cshtml b/src/Bifrost.Web/Pages/Shared/_Layout.cshtml index 34c68cb..6c757c9 100644 --- a/src/Bifrost.Web/Pages/Shared/_Layout.cshtml +++ b/src/Bifrost.Web/Pages/Shared/_Layout.cshtml @@ -17,7 +17,7 @@ - + - @await RenderSectionAsync("Scripts", required: false) diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index bf6103a..ef92be8 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -7,12 +7,14 @@ using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Bridge; using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Desk; using Bifrost.Web.Features.GitHub; using Bifrost.Web.Features.Heimdall; using Bifrost.Web.Features.Membership; using Bifrost.Web.Features.Motions; using Bifrost.Web.Features.Oidc; using Bifrost.Web.Features.Patronage; +using Bifrost.Web.Features.Registration; using Bifrost.Web.Features.Shared; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -62,7 +64,8 @@ builder.Services builder.Services .AddOptions() - .BindConfiguration(HeimdallOptions.SectionName); + .BindConfiguration(HeimdallOptions.SectionName) + .PostConfigure>(HeimdallOptions.ResolveAppSecret); builder.Services .AddOptions() @@ -76,6 +79,10 @@ builder.Services .AddOptions() .BindConfiguration(ConceptVoteOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(DeskOptions.SectionName); + builder.Services.AddAntiforgery(options => { options.Cookie.Name = BifrostCookies.Antiforgery; @@ -112,6 +119,7 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -125,6 +133,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddOptions().BindConfiguration(MindIntakeOptions.SectionName); builder.Services.AddSingleton(); // TimeProvider and options resolve from the container +builder.Services.AddOptions().BindConfiguration(RegistrationOptions.SectionName); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -140,7 +152,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddHttpClient((serviceProvider, client) => HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) - .RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase)); + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); builder.Services.AddSingleton(); builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); @@ -252,6 +264,13 @@ if (oidcOptions is not null) builder.Services.AddBifrostOidc(oidcOptions); } +// The desk is mapped only when the Desk section exists, and a configured desk needs its credential and door ports. +var deskConfigured = builder.Configuration.GetSection(DeskOptions.SectionName).Exists(); +if (deskConfigured) +{ + builder.Services.AddBifrostDesk(oidcConfigured: oidcOptions is not null); +} + var app = builder.Build(); var appJsonSerializerOptions = CreateAppJsonSerializerOptions(); @@ -273,6 +292,11 @@ if (!app.Environment.IsDevelopment()) app.UseHttpLogging(); app.UseHttpsRedirection(); +if (oidcOptions is not null) +{ + app.UseMiddleware(); // before authentication: OpenIddict answers /connect/* inside it +} + app.UseStaticFiles(); app.UseRouting(); app.UseConceptTallyCors(); @@ -293,12 +317,18 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke }).AllowAnonymous(); app.MapConceptTallyExport(); +app.MapConceptRankedExport(); if (oidcOptions is not null) { app.MapBifrostOidc(); } +if (deskConfigured) +{ + app.MapDesk(); +} + app.MapPost("/github/webhooks", async ( HttpRequest request, GitHubWebhookService gitHubWebhookService, @@ -361,35 +391,173 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions { + if (rateLimit.Refuse(httpContext) is { } refused) + { + return refused; + } + + var outcome = await registration.RunAsync( + httpContext, + new RegistrationAttempt(request.Identity, request.DisplayName, request.Challenge, request.Nonce, request.Waited == true, request.AcceptedNoticeVersion), + cancellationToken); + if (outcome.Refusal is not null) + { + return outcome.Refusal; + } + + if (outcome.Account is not { } userAccount) + { + return Results.Text(outcome.Message!, statusCode: outcome.Status); + } + + var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl; + return JsonResult( + new NativeBifrostRegistrationResponse( + userAccount.Id, + userAccount.BifrostIdentity, + userAccount.DisplayName, + userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(), + returnUrl), + appJsonSerializerOptions); +}).AllowAnonymous(); + +// GET issues a challenge, so it spends the same per-address budget as /auth/bifrost/challenge. +app.MapGet("/auth/bifrost/register", ( + HttpContext httpContext, + RegistrationChallengeService challenges, + RegistrationRateLimit rateLimit, + IOptions registrationOptions) => +{ + if (rateLimit.Refuse(httpContext) is { } refused) + { + return refused; + } + + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); + return RegistrationFormResult(httpContext, challenges, registrationOptions.Value, new RegistrationPageModel(returnPath.Value, "", 0, registrationOptions.Value.Captcha.FallbackWaitSeconds), null, StatusCodes.Status200OK); +}).AllowAnonymous(); + +// No antiforgery token or cookie exists for an anonymous visitor, so a cross-site post is refused by origin instead +// (RequestOriginGuard, the same Fetch Metadata check as the vote page, except that a request with no Origin and no +// Sec-Fetch-Site and no session cookie passes: text browsers send neither, and there is no session to replace). Without it a foreign page could post here with +// a challenge of its own and replace the visitor's session cookie with an empty account holding a forged consent. +// Order: origin, then the general address budget, then the body cap, then the form; a refusal sets no cookie. +app.MapPost("/auth/bifrost/register/form", async ( + HttpContext httpContext, + NativeRegistration registration, + RegistrationChallengeService challenges, + RegistrationRateLimit rateLimit, + IOptions hostOptions, + IOptions registrationOptions, + CancellationToken cancellationToken) => +{ + if (!RequestOriginGuard.AllowsFormWithoutFetchMetadata(httpContext.Request, hostOptions.Value)) + { + return Results.Text("Cross-site registration is refused.", statusCode: StatusCodes.Status403Forbidden); + } + + if (rateLimit.Refuse(httpContext) is { } refused) + { + return refused; + } + + if (!(httpContext.Request.ContentType ?? "").StartsWith("application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) + { + return Results.Text("Send the form as application/x-www-form-urlencoded.", statusCode: StatusCodes.Status415UnsupportedMediaType); + } + + // The form is a handful of short fields: refuse a big body before reading any of it. + const int MaxFormBytes = 16 * 1024; + if (httpContext.Request.ContentLength > MaxFormBytes) + { + return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge); + } + + if (httpContext.Features.Get() is { IsReadOnly: false } limit) + { + limit.MaxRequestBodySize = MaxFormBytes; + } + + IFormCollection form; try { - var userAccount = await bifrostIdentityService.RegisterNativeAsync( - request.Identity, - request.DisplayName, - cancellationToken); - await httpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - bifrostIdentityService.BuildPrincipal(userAccount)); - - var returnUrl = string.IsNullOrWhiteSpace(request.ReturnUrl) ? "/App" : request.ReturnUrl; - return JsonResult( - new NativeBifrostRegistrationResponse( - userAccount.Id, - userAccount.BifrostIdentity, - userAccount.DisplayName, - userAccount.Membership?.Status.ToString() ?? MembershipStatus.Authenticated.ToString(), - returnUrl), - appJsonSerializerOptions); + form = await httpContext.Request.ReadFormAsync(cancellationToken); } - catch (BifrostIdentityException error) + catch (BadHttpRequestException error) when (error.StatusCode == StatusCodes.Status413PayloadTooLarge) { - return Results.Text(error.Message, statusCode: StatusCodes.Status400BadRequest); + return Results.Text("The form is too large.", statusCode: StatusCodes.Status413PayloadTooLarge); } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return Results.Text("The form could not be read.", statusCode: StatusCodes.Status400BadRequest); + } + + string Field(string name) => form[name].FirstOrDefault() ?? ""; + var returnPath = LocalReturnPath.Parse(Field("returnUrl")); + // The checkbox is the consent act and is optional: unticked, the posted notice version is ignored and no receipt is made. + // Anything but the canonical decimal of the version in force ("02" included) with the box ticked is sent as -1 and refused, never read as "did not agree". + int? accepted = Field("consent") != "yes" + ? null + : Field("acceptedNoticeVersion") == TrainingNotice.Version.ToString(System.Globalization.CultureInfo.InvariantCulture) ? TrainingNotice.Version : -1; + var identity = Field("identity"); + var displayName = Field("displayName"); + var challenge = Field("challenge"); + + var outcome = await registration.RunAsync( + httpContext, + new RegistrationAttempt(identity, displayName, challenge, Field("nonce"), Field("waited") == "true", accepted), + cancellationToken); + if (outcome.Refusal is not null) + { + return outcome.Refusal; + } + + if (outcome.Account is not null) + { + // A Location header takes ASCII only; anything else is not a path worth keeping. + var target = returnPath.Value.All(c => c > ' ' && c < '\u007f') ? returnPath.Value : LocalReturnPath.Default.Value; + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers.Location = target; + return Results.StatusCode(StatusCodes.Status303SeeOther); + } + + // A refusal that spent nothing and needs only a correction (a taken identity, a too-early time-lock) keeps its + // challenge, whose clock must not restart. Any other failure gets a fresh challenge. The consent box always comes back unchecked. + var options = registrationOptions.Value; + return RegistrationFormResult( + httpContext, + challenges, + options, + new RegistrationPageModel(returnPath.Value, "", 0, options.Captcha.FallbackWaitSeconds, Truncate(identity), Truncate(displayName), outcome.Message, outcome.Field), + outcome.KeepChallenge && challenges.IsLive(challenge) ? challenge : null, + outcome.Status); + + static string Truncate(string text) => text.Length <= 256 ? text : text[..256]; }).AllowAnonymous(); +app.MapGet("/auth/bifrost/challenge", (HttpContext httpContext, RegistrationChallengeService challenges, RegistrationRateLimit rateLimit) => +{ + if (rateLimit.Refuse(httpContext) is { } refused) + { + return refused; + } + + httpContext.Response.Headers.CacheControl = "no-store"; + var challenge = challenges.Issue(); + return challenge is null + ? Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable) + : JsonResult(challenge, appJsonSerializerOptions); +}).AllowAnonymous(); + +app.MapGet("/auth/bifrost/notice", () => + JsonResult(new { version = TrainingNotice.Version, text = TrainingNotice.Text }, appJsonSerializerOptions)).AllowAnonymous(); + +app.MapConsent(); + app.MapGet("/auth/heimdall/{provider}", async ( string provider, HttpContext httpContext, @@ -658,8 +826,32 @@ app.MapPost("/auth/heimdall/complete", async ( var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken); var attempt = form?["attempt"].ToString(); var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt); + var linking = held is not null + && heimdallAuthService.PurposeOf(held.Value.Value, attempt) is not (null or HeimdallAttemptPurpose.SignIn); try { + if (linking) + { + // A link step never signs anyone in: the session in this browser is the one being linked. + var step = await heimdallAuthService.ContinueLinkAsync( + hostOptions.Value.PublicBaseUrl, + attempt, + held!.Value.Name, + held.Value.Value, + form?["code"].ToString(), + form?["pendingLinkId"].ToString(), + httpContext.User.FindFirstValue(BifrostClaimTypes.HeimdallAccountId), + cancellationToken); + if (step.Next is not null) + { + httpContext.Response.Cookies.Append(step.CookieName, step.CookieValue!, HeimdallAttemptCookie.Options()); + return Results.Redirect(step.Next.ToString()); + } + + httpContext.Response.Cookies.Delete(step.CookieName, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(step.Outcome!.Value)); + } + var signIn = await heimdallAuthService.CompleteAsync( hostOptions.Value.PublicBaseUrl, attempt, @@ -678,7 +870,9 @@ app.MapPost("/auth/heimdall/complete", async ( httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); } - return Results.Redirect(HeimdallAuthService.ErrorRedirect); + return linking + ? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error)) + : Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -715,6 +909,36 @@ static async Task ReadFormOrNullAsync(HttpRequest request, Can } } +// Renders the sign-up page with a challenge: the kept one when given, else a fresh one (null when the issuing set is full). +static IResult RegistrationFormResult( + HttpContext httpContext, + RegistrationChallengeService challenges, + RegistrationOptions options, + RegistrationPageModel model, + string? keepChallenge, + int statusCode) +{ + var token = keepChallenge; + var difficulty = options.Captcha.Difficulty; + if (token is null) + { + var issued = challenges.Issue(); + if (issued is null) + { + return Results.Text("Registration is busy; try again shortly.", statusCode: StatusCodes.Status503ServiceUnavailable); + } + + token = issued.Challenge; + difficulty = issued.Difficulty; + } + + httpContext.Response.Headers.CacheControl = "no-store"; + return Results.Content( + RegistrationPage.Render(model with { Challenge = token, Difficulty = difficulty }), + "text/html; charset=utf-8", + statusCode: statusCode); +} + static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl) { httpContext.Response.ContentType = "text/html; charset=utf-8"; @@ -768,7 +992,11 @@ public partial class Program; public sealed record NativeBifrostRegistrationRequest( string Identity, string? DisplayName, - string? ReturnUrl); + string? ReturnUrl, + string? Challenge, + string? Nonce, + int? AcceptedNoticeVersion, + bool? Waited = null); public sealed record NativeBifrostRegistrationResponse( Guid UserAccountId, 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..d969a56 100644 --- a/src/Bifrost.Web/packages.lock.json +++ b/src/Bifrost.Web/packages.lock.json @@ -8,6 +8,17 @@ "resolved": "2.4.0", "contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ==" }, + "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,6 +86,25 @@ "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, )", @@ -86,6 +116,16 @@ "resolved": "2.14.1", "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" }, + "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", @@ -269,6 +309,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 +327,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", @@ -323,6 +376,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", 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 new file mode 100644 index 0000000..77df76f --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs @@ -0,0 +1,562 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptRankedExportTests : IClassFixture +{ + private const string AllowedOrigin = "https://aetheria.gamecult.org"; + private static readonly DateTimeOffset Epoch = new(2026, 11, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly RankedFactory _factory; + + public ConceptRankedExportTests(RankedFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Net_ranks_up_minus_down_and_ties_break_by_key() + { + var c = await Seed(); + + var keys = Keys(await GetOk(c, "?sort=net")); + + Assert.Equal(["bravo", "alpha", "charlie", "delta", "echo"], keys); + } + + [Fact] + public async Task Default_order_is_net_and_the_default_page_is_everything_under_the_cap() + { + var c = await Seed(); + + var body = await GetOk(c, ""); + + Assert.Equal(Keys(await GetOk(c, "?sort=net")), Keys(body)); + Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind); + } + + [Fact] + public async Task Total_ranks_up_plus_down() + { + var c = await Seed(); + + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], Keys(await GetOk(c, "?sort=total"))); + } + + [Fact] + public async Task Newest_ranks_by_registration_time() + { + var c = await Seed(); + + Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], Keys(await GetOk(c, "?sort=newest"))); + } + + [Fact] + public async Task Items_carry_counts_and_title_only() + { + var c = await Seed(); + + var first = (await GetOk(c, "?limit=1")).GetProperty("items")[0]; + + Assert.Equal(["down", "key", "members", "title", "up"], first.EnumerateObject().Select(p => p.Name).Order().ToArray()); + Assert.Equal(["down", "up"], first.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray()); + } + + [Theory] + [InlineData("net")] + [InlineData("total")] + [InlineData("newest")] + public async Task Paging_walks_every_subject_once_in_the_same_order_as_one_big_page(string sort) + { + var c = await Seed(); + var whole = Keys(await GetOk(c, $"?sort={sort}")); + + var walked = new List(); + string? cursor = null; + var pages = 0; + do + { + var body = await GetOk(c, $"?sort={sort}&limit=2" + (cursor is null ? "" : "&cursor=" + cursor)); + walked.AddRange(Keys(body)); + cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString(); + pages++; + } + while (cursor is not null && pages < 10); + + Assert.Equal(whole, walked); + Assert.Equal(3, pages); + } + + [Fact] + public async Task Ties_break_by_ordinal_key_even_when_keys_hold_hyphens_and_were_registered_out_of_order() + { + var c = "c" + Guid.NewGuid().ToString("N"); + string[] ordinal = ["a-b", "a-c", "ab", "b", "b-a"]; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + foreach (var key in ordinal.Reverse()) + { + db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key, RegisteredAtUtc = Epoch }); + } + + await db.SaveChangesAsync(); + } + + var walked = new List(); + string? cursor = null; + do + { + var body = await GetOk(c, "?limit=1" + (cursor is null ? "" : "&cursor=" + cursor)); + walked.AddRange(Keys(body)); + cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString(); + } + while (cursor is not null && walked.Count < 10); + + Assert.Equal(ordinal, walked); + Assert.Equal(ordinal, Keys(await GetOk(c, "?sort=newest"))); + } + + [Fact] + public async Task Unvoted_without_a_client_address_still_pages() + { + var c = await Seed(); + + var first = await GetOk(c, "?unvoted=network&limit=2"); + var second = await GetOk(c, "?unvoted=network&limit=2&cursor=" + first.GetProperty("next_cursor").GetString()); + + Assert.Equal(["bravo", "alpha"], Keys(first)); + Assert.Equal(["charlie", "delta"], Keys(second)); + } + + [Fact] + public async Task A_vote_between_pages_never_repeats_the_cursors_own_row() + { + var c = await Seed(); + var first = await GetOk(c, "?limit=2"); + var seen = Keys(first); + + await Cast(c, "echo", "mover-1", ConceptStance.Up); + await Cast(c, "echo", "mover-2", ConceptStance.Up); + await Cast(c, "echo", "mover-3", ConceptStance.Up); + await Cast(c, "echo", "mover-4", ConceptStance.Up); + var second = Keys(await GetOk(c, "?limit=5&cursor=" + first.GetProperty("next_cursor").GetString())); + + Assert.Empty(seen.Intersect(second)); + } + + [Theory] + [InlineData("?limit=0")] + [InlineData("?limit=101")] + [InlineData("?limit=-1")] + [InlineData("?limit=+5")] + [InlineData("?limit=05")] + [InlineData("?limit=5.0")] + [InlineData("?limit=%205")] + [InlineData("?limit=")] + [InlineData("?limit=abc")] + [InlineData("?limit=99999999999999999999")] + [InlineData("?sort=")] + [InlineData("?sort=oldest")] + [InlineData("?sort=Net")] + [InlineData("?sort=net&sort=total")] + [InlineData("?limit=1&limit=2")] + [InlineData("?unvoted=")] + [InlineData("?unvoted=all")] + [InlineData("?unvoted=network&unvoted=network")] + [InlineData("?Sort=net")] + [InlineData("?tag=abc")] + [InlineData("?networkTag=abc")] + [InlineData("?page=2")] + public async Task A_query_outside_the_closed_shape_is_a_400(string query) + { + var c = await Seed(); + + using var response = await Get(c, query); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("request_invalid", JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.GetProperty("error").GetString()); + } + + [Fact] + public async Task The_page_cap_is_one_hundred_and_one_hundred_is_allowed() + { + var c = await Seed(); + + using var ok = await Get(c, "?limit=100"); + using var over = await Get(c, "?limit=101"); + + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, over.StatusCode); + } + + public static IEnumerable HostileCursors() + { + static string B64(string text) => Convert.ToBase64String(Encoding.UTF8.GetBytes(text)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + yield return ["x"]; + yield return ["%00"]; + yield return ["not base64!"]; + yield return ["AAAA"]; + yield return [new string('A', 201)]; + // 50,000 chars is the longest the client Uri (65,519 cap on Unix) can carry with the path; far past the 200-char RankCursor.MaxLength. + yield return [new string('A', 50_000)]; + yield return [B64("")]; + yield return [B64("1|0|0|5")]; + yield return [B64("1|0|0|5|alpha|extra")]; + yield return [B64("2|0|0|5|alpha")]; + yield return [B64("1|3|0|5|alpha")]; + yield return [B64("1|-1|0|5|alpha")]; + yield return [B64("1|0|2|5|alpha")]; + yield return [B64("1|0|0|abc|alpha")]; + yield return [B64("1|0|0|+5|alpha")]; + yield return [B64("1|0|0|-0|alpha")]; + yield return [B64("1|0|0|05|alpha")]; + yield return [B64("1|0|0|99999999999999999999|alpha")]; + yield return [B64("1|0|0|5|Alpha")]; + yield return [B64("1|0|0|5|al pha")]; + yield return [B64("1|0|0|5|alpha\n")]; + yield return [B64("1|0|0|5|'; drop table")]; + yield return [B64("1|0|0|5|../x")]; + yield return [B64("1|0|0|5|" + new string('a', 81))]; + yield return [B64("1|0|0|5|")]; + yield return [Convert.ToBase64String(Encoding.UTF8.GetBytes("1|0|0|5|alpha"))]; + yield return [B64("1|0|0|5|alpha") + "="]; + yield return [Convert.ToBase64String([0xFF, 0xFE, 0xFD, 0xFC]).Replace('+', '-').Replace('/', '_').TrimEnd('=')]; + } + + [Theory] + [MemberData(nameof(HostileCursors))] + public async Task A_hostile_cursor_is_a_400_and_never_a_query(string cursor) + { + var c = await Seed(); + + using var response = await Get(c, "?cursor=" + Uri.EscapeDataString(cursor)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task A_cursor_is_bound_to_the_sort_and_the_filter_it_was_issued_for() + { + var c = await Seed(); + var net = (await GetOk(c, "?limit=1")).GetProperty("next_cursor").GetString()!; + var unvoted = (await GetOk(c, "?limit=1&unvoted=network", client: "198.51.100.9")).GetProperty("next_cursor").GetString()!; + + using var otherSort = await Get(c, "?sort=total&cursor=" + net); + using var dropsFilter = await Get(c, "?cursor=" + unvoted); + using var addsFilter = await Get(c, "?unvoted=network&cursor=" + net, client: "198.51.100.9"); + using var same = await Get(c, "?limit=1&cursor=" + net); + + Assert.Equal(HttpStatusCode.BadRequest, otherSort.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, dropsFilter.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, addsFilter.StatusCode); + Assert.Equal(HttpStatusCode.OK, same.StatusCode); + } + + [Fact] + public async Task A_well_formed_cursor_past_every_row_is_an_empty_last_page() + { + var c = await Seed(); + var past = new RankCursor(RankOrder.Net, false, long.MinValue, "a").Encode(); + + var body = await GetOk(c, "?cursor=" + past); + + Assert.Empty(Keys(body)); + Assert.Equal(JsonValueKind.Null, body.GetProperty("next_cursor").ValueKind); + } + + [Theory] + [InlineData("Alpha")] + [InlineData("a_b")] + [InlineData("..")] + [InlineData("a%2Fb")] + [InlineData("a%0A")] + [InlineData("%27%3B--")] + [InlineData("nope")] + public async Task A_hostile_or_unknown_collection_is_a_404(string collection) + { + using var response = await Get(collection, ""); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task A_collection_over_the_key_grammar_is_a_404() + { + using var response = await Get(new string('a', 81), ""); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Retired_subjects_are_left_out() + { + var c = await Seed(); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + (await db.ConceptSubjects.SingleAsync(x => x.Collection == c && x.Key == "bravo")).RetiredAtUtc = Epoch; + await db.SaveChangesAsync(); + } + + Assert.DoesNotContain("bravo", Keys(await GetOk(c, ""))); + } + + [Fact] + public async Task Unvoted_leaves_out_what_the_callers_network_voted_and_not_what_others_did() + { + var c = await Seed(); + + var fromVoter = Keys(await GetOk(c, "?unvoted=network", client: Voter)); + var fromStranger = Keys(await GetOk(c, "?unvoted=network", client: "198.51.100.77")); + + Assert.Equal(["charlie", "delta", "echo"], fromVoter.Order().ToArray()); + Assert.Equal(["alpha", "bravo", "charlie", "delta", "echo"], fromStranger.Order().ToArray()); + } + + [Fact] + public async Task Unvoted_pages_and_ranks_the_same_way() + { + var c = await Seed(); + + var walked = new List(); + string? cursor = null; + do + { + var body = await GetOk(c, "?unvoted=network&sort=newest&limit=1" + (cursor is null ? "" : "&cursor=" + cursor), client: "198.51.100.77"); + walked.AddRange(Keys(body)); + cursor = body.GetProperty("next_cursor").ValueKind == JsonValueKind.Null ? null : body.GetProperty("next_cursor").GetString(); + } + while (cursor is not null && walked.Count < 10); + + Assert.Equal(["echo", "delta", "charlie", "bravo", "alpha"], walked); + } + + [Fact] + public async Task Unvoted_without_a_client_address_filters_nothing_and_matches_no_untagged_row() + { + var c = await Seed(); + await CastUntagged(c, "echo", "ghost-device"); + + var keys = Keys(await GetOk(c, "?unvoted=network", client: null)); + + Assert.Equal(5, keys.Length); + } + + [Fact] + public async Task Unvoted_and_plain_responses_expose_no_tag_or_voter_identity() + { + var c = await Seed(); + string[] tags; + using (var scope = _factory.Services.CreateScope()) + { + tags = await scope.ServiceProvider.GetRequiredService() + .ConceptAppraisals.Where(x => x.NetworkTag != "").Select(x => x.NetworkTag).Distinct().ToArrayAsync(); + } + + Assert.NotEmpty(tags); + foreach (var query in new[] { "", "?unvoted=network", "?limit=2&unvoted=network" }) + { + using var response = await Get(c, query, client: Voter); + var text = await response.Content.ReadAsStringAsync(); + foreach (var forbidden in tags.Concat(["voter-", "device:", "account:", "networktag", "voterkey", Voter])) + { + Assert.DoesNotContain(forbidden, text, StringComparison.OrdinalIgnoreCase); + } + } + + // The cursor is position only: it carries no tag either. + var cursor = (await GetOk(c, "?limit=1&unvoted=network", client: Voter)).GetProperty("next_cursor").GetString()!; + var decoded = Encoding.UTF8.GetString(Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlDecode(cursor)); + foreach (var tag in tags) + { + Assert.DoesNotContain(tag, decoded); + } + } + + [Fact] + public async Task Nothing_in_the_request_can_name_a_tag_or_a_client() + { + var c = await Seed(); + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{c}/ranked?unvoted=network"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.Add("X-Test-Client", "198.51.100.77"); + request.Headers.Add("X-Network-Tag", "anything"); + request.Headers.Add("X-Forwarded-For", Voter); + + using var response = await client.SendAsync(request); + + // The test connection address wins; the forwarded header is the proxy's to set, not the request's. + Assert.Equal(5, Keys(JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement).Length); + } + + [Fact] + public async Task Cache_control_is_public_for_the_plain_read_and_never_cached_for_the_unvoted_one() + { + var c = await Seed(); + + using var plain = await Get(c, ""); + using var unvoted = await Get(c, "?unvoted=network", client: Voter); + + Assert.True(plain.Headers.CacheControl!.Public); + Assert.Equal(TimeSpan.FromSeconds(60), plain.Headers.CacheControl.MaxAge); + Assert.True(unvoted.Headers.CacheControl!.Private); + Assert.True(unvoted.Headers.CacheControl.NoStore); + Assert.False(unvoted.Headers.CacheControl.Public); + } + + [Fact] + public async Task Cors_and_vary_follow_the_tally_export_policy_and_the_read_sets_no_cookie_and_writes_nothing() + { + var c = await Seed(); + int rows, audits; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + rows = db.ConceptAppraisals.Count(); + audits = db.AuditEvents.Count(); + } + + using var allowed = await Get(c, "", origin: AllowedOrigin); + using var other = await Get(c, "", origin: "https://evil.example"); + using var bad = await Get(c, "?limit=0", origin: AllowedOrigin); + + Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials")); + Assert.False(other.Headers.Contains("Access-Control-Allow-Origin")); + foreach (var response in new[] { allowed, other, bad }) + { + Assert.Contains("Origin", response.Headers.Vary.ToArray()); + Assert.False(response.Headers.Contains("Set-Cookie")); + } + + using var after = _factory.Services.CreateScope(); + var dbAfter = after.ServiceProvider.GetRequiredService(); + Assert.Equal(rows, dbAfter.ConceptAppraisals.Count()); + Assert.Equal(audits, dbAfter.AuditEvents.Count()); + } + + // The network that cast the votes in Seed. + private const string Voter = "203.0.113.7"; + + // alpha 3 up 2 down (net 1, total 5), bravo 2 up (net 2, total 2), charlie 1 up 1 down (net 0, total 2), + // delta and echo unvoted (net 0, total 0). Registered alpha first, echo last. Voter's network cast alpha and + // bravo; charlie's votes came from a second network. + private async Task Seed() + { + var c = "c" + Guid.NewGuid().ToString("N"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var i = 0; + foreach (var key in new[] { "alpha", "bravo", "charlie", "delta", "echo" }) + { + db.ConceptSubjects.Add(new ConceptSubject { Collection = c, Key = key, Title = key.ToUpperInvariant(), RegisteredAtUtc = Epoch.AddDays(i++) }); + } + + await db.SaveChangesAsync(); + } + + await Cast(c, "alpha", "voter-1", ConceptStance.Up); + await Cast(c, "alpha", "voter-2", ConceptStance.Up); + await Cast(c, "alpha", "voter-3", ConceptStance.Up); + await Cast(c, "alpha", "voter-4", ConceptStance.Down); + await Cast(c, "alpha", "voter-5", ConceptStance.Down); + await Cast(c, "bravo", "voter-1", ConceptStance.Up); + await Cast(c, "bravo", "voter-2", ConceptStance.Up); + await Cast(c, "charlie", "other-1", ConceptStance.Up, "192.0.2.50"); + await Cast(c, "charlie", "other-2", ConceptStance.Down, "192.0.2.50"); + return c; + } + + private async Task Cast(string collection, string key, string device, ConceptStance stance, string client = Voter) + { + using var scope = _factory.Services.CreateScope(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!; + var result = await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, stance, IPAddress.Parse(client), default); + Assert.Equal(CastResult.Recorded, result); + } + + private async Task CastUntagged(string collection, string key, string device) + { + using var scope = _factory.Services.CreateScope(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, device)!; + await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, ConceptStance.Up, null, default); + } + + private async Task Get(string collection, string query, string? client = null, string? origin = null) + { + using var http = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/ranked{query}"); + request.Headers.Add("X-Test-Anonymous", "true"); + if (client is not null) + { + request.Headers.Add("X-Test-Client", client); + } + + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + return await http.SendAsync(request); + } + + private async Task GetOk(string collection, string query, string? client = null) + { + using var response = await Get(collection, query, client); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement.Clone(); + } + + private static string[] Keys(JsonElement body) => + body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("key").GetString()!).ToArray(); + + // The test host has no connection address; a header stands in for the one the proxy path would have resolved. + private sealed class ClientAddressFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + if (context.Request.Headers.TryGetValue("X-Test-Client", out var address)) + { + context.Connection.RemoteIpAddress = IPAddress.Parse(address.ToString()); + } + + return nextMiddleware(context); + }); + next(app); + }; + } + + public sealed class RankedFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => services.AddTransient()); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin, + ["ConceptVotes:NetworkTagSecret"] = "ranked-test-tag-secret-0123456789-abcdef" + })); + } + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs index d334541..7510ed8 100644 --- a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -516,6 +516,96 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture(() => factory.CreateClient()); + + Assert.Contains(key, Messages(failure)); + } + + // Boundary lengths start in Development and Testing and a cast from a real client address works. Production is + // not started here: it additionally demands a database, keys path and secrets this factory does not supply. + [Theory] + [InlineData("Development", 0, 0)] + [InlineData("Development", 32, 128)] + [InlineData("Testing", 0, 0)] + [InlineData("Testing", 32, 128)] + public async Task Boundary_prefix_lengths_start_and_a_cast_from_a_client_address_succeeds( + string environment, int v4, int v6) + { + using var factory = WithPrefixes(environment, v4, v6); + var collection = await NewSubject(factory); + + using var v4Cast = await new Client(factory, "203.0.113.200").Post(collection, "Up"); + using var v6Cast = await new Client(factory, "2001:db8:5::1").Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, v4Cast.StatusCode); + Assert.Equal(HttpStatusCode.Redirect, v6Cast.StatusCode); + } + + [Fact] + public async Task A_zero_length_prefix_puts_every_address_of_the_family_in_one_bucket() + { + using var factory = WithPrefixes("Development", 0, 0); + var collection = await NewSubject(factory); + // One device (one cookie, one mint) arriving from changing addresses: only the cast bucket can refuse it. + var device = new Client(factory, "198.51.100.1"); + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", $"198.51.100.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", "192.0.2.77"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + private WebApplicationFactory WithPrefixes(string environment, int v4, int v6) => + _factory.WithWebHostBuilder(b => + { + b.UseEnvironment(environment); + b.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:IPv4PrefixLength"] = v4.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["ConceptVotes:IPv6PrefixLength"] = v6.ToString(System.Globalization.CultureInfo.InvariantCulture) + })); + }); + + private static string Messages(Exception failure) + { + var lines = new List(); + for (var e = failure; e is not null; e = e.InnerException!) + { + lines.Add(e.Message); + if (e.InnerException is null) + { + break; + } + } + + return string.Join("\n", lines); + } + private WebApplicationFactory Capped(int maxTrackedKeys) => _factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary @@ -531,10 +621,12 @@ public sealed class ConceptVoteRateLimitTests : IClassFixture x.StartsWith(CookieName + "=", StringComparison.Ordinal)) : null; - private async Task NewSubject() + private Task NewSubject() => NewSubject(_factory); + + private static async Task NewSubject(WebApplicationFactory factory) { var collection = "c" + Guid.NewGuid().ToString("N"); - using var scope = _factory.Services.CreateScope(); + using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.ConceptSubjects.Add(new ConceptSubject { diff --git a/tests/Bifrost.Web.Tests/ConsentRaceAndModelTests.cs b/tests/Bifrost.Web.Tests/ConsentRaceAndModelTests.cs new file mode 100644 index 0000000..86cfcaa --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConsentRaceAndModelTests.cs @@ -0,0 +1,159 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Data.Migrations; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Registration; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations.Operations; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +/// Throws the store's refusal when a receipt is inserted and the flag is up, as the filtered unique index does to the loser of a race. +public sealed class UniqueViolationInterceptor : SaveChangesInterceptor +{ + public volatile bool Armed; + + /// The SqlState to wrap in the thrown exception. + public volatile string SqlState = Npgsql.PostgresErrorCodes.UniqueViolation; + + public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (Armed && eventData.Context!.ChangeTracker.Entries().Any(x => x.State == EntityState.Added)) + { + throw new DbUpdateException( + "An error occurred while saving the entity changes.", + new Npgsql.PostgresException("store refused the insert", "ERROR", "ERROR", SqlState)); + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } +} + +public sealed class UniqueViolationFactory : TestWebApplicationFactory +{ + public UniqueViolationInterceptor Interceptor { get; } = new(); + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + { + // Wrap the options the base factory registered so the interceptor rides on the same in-memory store. + var original = services.Single(x => x.ServiceType == typeof(DbContextOptions)); + services.Remove(original); + services.Add(ServiceDescriptor.Describe( + typeof(DbContextOptions), + sp => new DbContextOptionsBuilder((DbContextOptions)original.ImplementationFactory!(sp)).AddInterceptors(Interceptor).Options, + original.Lifetime)); + }); + } +} + +public sealed class ConsentRaceTests(UniqueViolationFactory factory) : IClassFixture +{ + private async Task AcceptWithStoreFailureAsync(string sqlState, string identity) + { + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/accept") + { + Content = new FormUrlEncodedContent(new Dictionary { ["version"] = TrainingNotice.Version.ToString() }) + }; + request.Headers.Add("X-Test-Bifrost-Identity", identity); + request.Headers.Add("Origin", "https://localhost"); + + factory.Interceptor.SqlState = sqlState; + factory.Interceptor.Armed = true; + try + { + return await client.SendAsync(request); + } + finally + { + factory.Interceptor.Armed = false; + } + } + + private Guid NewAccount(string identity) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity }; + db.UserAccounts.Add(account); + db.SaveChanges(); + return account.Id; + } + + private bool HasReceipt(Guid accountId) + { + using var scope = factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService().ConsentReceipts.Any(x => x.UserAccountId == accountId); + } + + [Fact] + public async Task The_loser_of_an_accept_race_gets_303_not_500_and_no_second_receipt() + { + var identity = $"race-{Guid.NewGuid():N}"; + var accountId = NewAccount(identity); + + using var response = await AcceptWithStoreFailureAsync(Npgsql.PostgresErrorCodes.UniqueViolation, identity); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.Equal("/auth/bifrost/consent", response.Headers.Location!.OriginalString); + Assert.False(HasReceipt(accountId)); + } + + [Theory] + [InlineData(Npgsql.PostgresErrorCodes.ForeignKeyViolation)] + [InlineData(Npgsql.PostgresErrorCodes.DeadlockDetected)] + [InlineData(Npgsql.PostgresErrorCodes.NotNullViolation)] + public async Task Any_other_store_fault_on_accept_is_a_500_not_a_silent_redirect(string sqlState) + { + var identity = $"fault-{Guid.NewGuid():N}"; + var accountId = NewAccount(identity); + + using var response = await AcceptWithStoreFailureAsync(sqlState, identity); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.False(HasReceipt(accountId)); + } +} + +// The in-memory provider ignores index filters, so the model and the migration are checked directly. +public sealed class ConsentReceiptIndexTests +{ + private const string Filter = "WithdrawnAtUtc\" IS NULL"; + + private static IIndex ReceiptIndex(IModel model) => + model.FindEntityType(typeof(ConsentReceipt))!.GetIndexes() + .Single(x => x.Properties.Select(p => p.Name).SequenceEqual(["UserAccountId", "NoticeVersion"])); + + [Fact] + public void The_design_time_model_and_the_snapshot_have_a_filtered_unique_index_on_live_receipts() + { + using var db = new BifrostDbContext(new DbContextOptionsBuilder().UseNpgsql("Host=unused").Options); + + foreach (var model in new[] { db.GetService().Model, db.GetService().ModelSnapshot!.Model }) + { + var index = ReceiptIndex(model); + Assert.True(index.IsUnique); + Assert.Contains(Filter, index.GetFilter()); + } + } + + [Fact] + public void The_latest_consent_migration_creates_the_same_filter() + { + var up = new ConsentReceiptsOneLivePerVersion().UpOperations.OfType() + .Single(x => x.Table == "ConsentReceipts"); + + Assert.True(up.IsUnique); + Assert.Contains(Filter, up.Filter); + Assert.Equal(["UserAccountId", "NoticeVersion"], up.Columns); + } +} diff --git a/tests/Bifrost.Web.Tests/ConsentTests.cs b/tests/Bifrost.Web.Tests/ConsentTests.cs new file mode 100644 index 0000000..e4e088e --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConsentTests.cs @@ -0,0 +1,452 @@ +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Registration; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +// The consent page and its posts, through HTTP, against receipt rows in the database. +public sealed class ConsentTests(RegistrationGateFactory factory) : IClassFixture +{ + private const string Origin = "https://localhost"; + private static readonly DateTimeOffset Agreed = new(2026, 11, 1, 12, 30, 5, TimeSpan.Zero); + private static readonly DateTimeOffset Withdrew = new(2026, 11, 2, 8, 0, 9, TimeSpan.Zero); + + private static string NewIdentity() => $"consent-{Guid.NewGuid():N}"; + + private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private void Seed(string identity, params (int Version, DateTimeOffset Accepted, DateTimeOffset? Withdrawn)[] receipts) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity }; + db.UserAccounts.Add(account); + foreach (var (version, accepted, withdrawn) in receipts) + { + db.ConsentReceipts.Add(new ConsentReceipt { UserAccount = account, NoticeVersion = version, AcceptedAtUtc = accepted, WithdrawnAtUtc = withdrawn }); + } + + db.SaveChanges(); + } + + private List Receipts(string identity) + { + using var scope = factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService().ConsentReceipts.AsNoTracking() + .Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity).OrderBy(x => x.AcceptedAtUtc).ToList(); + } + + private async Task GetPageAsync(HttpClient client, string? identity) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/auth/bifrost/consent"); + Sign(request, identity); + return await client.SendAsync(request); + } + + private static void Sign(HttpRequestMessage request, string? identity) + { + if (identity is null) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + else + { + request.Headers.Add("X-Test-Bifrost-Identity", identity); + } + } + + private static async Task PostAsync( + HttpClient client, string action, string? identity, Dictionary? fields = null, string? origin = Origin, string? fetchSite = null) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/consent/" + action) + { + Content = new FormUrlEncodedContent(fields ?? new Dictionary { ["version"] = TrainingNotice.Version.ToString() }) + }; + Sign(request, identity); + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + + if (fetchSite is not null) + { + request.Headers.Add("Sec-Fetch-Site", fetchSite); + } + + return await client.SendAsync(request); + } + + private async Task PageHtmlAsync(string identity) + { + using var client = Client(); + using var response = await GetPageAsync(client, identity); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + + [Fact] + public async Task Anonymous_is_sent_to_the_sign_in_chooser_with_the_page_as_return_target() + { + using var client = Client(); + using var response = await GetPageAsync(client, null); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Equal("/auth/sign-in?returnUrl=%2Fauth%2Fbifrost%2Fconsent", response.Headers.Location!.OriginalString); + } + + [Fact] + public async Task The_page_shows_the_notice_in_force_and_is_plain_server_html() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + using var response = await GetPageAsync(client, identity); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + Assert.Contains("no-store", response.Headers.CacheControl!.ToString()); + foreach (var paragraph in TrainingNotice.Text.Split("\n\n")) + { + Assert.Contains(WebUtility.HtmlEncode(paragraph), html); + } + + Assert.Contains("", html); + Assert.Contains("aria-live=\"polite\"", html); + Assert.DoesNotContain("= 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() + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, null)); + var html = await PageHtmlAsync(identity); + + Assert.Contains($"You agreed to version {TrainingNotice.Version} on 2026-11-01T12:30:05Z", html); + Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html); + Assert.DoesNotContain("/consent/accept", html); + } + + [Fact] + public async Task Agreement_to_an_older_version_says_it_no_longer_counts_and_offers_both_agree_and_withdraw() + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version - 1, Agreed, null)); + var html = await PageHtmlAsync(identity); + + 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); + } + + [Fact] + public async Task A_live_receipt_for_the_version_in_force_outranks_an_older_live_one() + { + var identity = NewIdentity(); + 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); + Assert.Contains("action=\"/auth/bifrost/consent/withdraw\"", html); + } + + [Fact] + public async Task A_withdrawal_shows_its_time_and_offers_agree() + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, Withdrew)); + var html = await PageHtmlAsync(identity); + + Assert.Contains("You withdrew on 2026-11-02T08:00:09Z", html); + Assert.DoesNotContain("You agreed to version", html); + Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); + Assert.DoesNotContain("/consent/withdraw", html); + } + + [Fact] + public async Task Accept_creates_one_live_receipt_and_a_second_accept_adds_none() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var first = await PostAsync(client, "accept", identity); + factory.Time.Advance(TimeSpan.FromHours(1)); + using var second = await PostAsync(client, "accept", identity); + + Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode); + Assert.Equal("/auth/bifrost/consent", first.Headers.Location!.OriginalString); + Assert.Equal(HttpStatusCode.SeeOther, second.StatusCode); + var receipt = Assert.Single(Receipts(identity)); + Assert.Equal(TrainingNotice.Version, receipt.NoticeVersion); + Assert.Null(receipt.WithdrawnAtUtc); + Assert.Contains("You agreed to version", await PageHtmlAsync(identity)); + } + + [Fact] + public async Task Withdraw_stamps_once_and_a_second_withdraw_changes_nothing() + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, null)); + using var client = Client(); + + using var first = await PostAsync(client, "withdraw", identity); + var stamped = Assert.Single(Receipts(identity)).WithdrawnAtUtc; + factory.Time.Advance(TimeSpan.FromHours(1)); + using var second = await PostAsync(client, "withdraw", identity); + + Assert.Equal(HttpStatusCode.SeeOther, first.StatusCode); + Assert.Equal(HttpStatusCode.SeeOther, second.StatusCode); + Assert.NotNull(stamped); + Assert.Equal(stamped, Assert.Single(Receipts(identity)).WithdrawnAtUtc); + } + + [Fact] + public async Task Withdraw_stamps_every_live_receipt_including_an_older_version() + { + var identity = NewIdentity(); + 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); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.All(Receipts(identity), x => Assert.NotNull(x.WithdrawnAtUtc)); + } + + [Fact] + public async Task Withdraw_with_no_receipt_changes_nothing() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "withdraw", identity); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.Empty(Receipts(identity)); + } + + [Fact] + public async Task Accept_after_withdraw_makes_a_new_live_receipt_and_the_old_one_stays_withdrawn() + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, Withdrew)); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + var receipts = Receipts(identity); + Assert.Equal(2, receipts.Count); + Assert.Single(receipts, x => x.WithdrawnAtUtc == Withdrew && x.AcceptedAtUtc == Agreed); + Assert.Single(receipts, x => x.WithdrawnAtUtc is null && x.NoticeVersion == TrainingNotice.Version); + } + + [Fact] + public async Task Accept_next_to_an_older_live_receipt_adds_one_for_the_version_in_force() + { + var identity = NewIdentity(); + 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([TrainingNotice.Version - 1, TrainingNotice.Version], Receipts(identity).Select(x => x.NoticeVersion).Order().ToArray()); + } + + [Theory] + [InlineData("0")] + [InlineData("2")] + [InlineData("3")] + [InlineData("abc")] + [InlineData("")] + public async Task Accept_with_the_wrong_version_is_refused_and_creates_nothing(string version) + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, new Dictionary { ["version"] = version }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("role=\"alert\"", await response.Content.ReadAsStringAsync()); + Assert.Empty(Receipts(identity)); + } + + [Fact] + public async Task Accept_with_no_version_field_is_refused_and_creates_nothing() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, new Dictionary { ["other"] = "x" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Empty(Receipts(identity)); + } + + [Theory] + [InlineData("https://evil.example", null)] + [InlineData(null, "cross-site")] + [InlineData(null, "same-site")] + [InlineData(null, null)] + public async Task Accept_from_another_origin_or_with_no_fetch_metadata_is_forbidden_and_changes_nothing(string? origin, string? fetchSite) + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, origin: origin, fetchSite: fetchSite); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(Receipts(identity)); + } + + [Theory] + [InlineData("https://evil.example", null)] + [InlineData(null, "cross-site")] + [InlineData(null, "same-site")] + [InlineData(null, null)] + public async Task Withdraw_from_another_origin_or_with_no_fetch_metadata_is_forbidden_and_changes_nothing(string? origin, string? fetchSite) + { + var identity = NewIdentity(); + Seed(identity, (TrainingNotice.Version, Agreed, null)); + using var client = Client(); + + using var response = await PostAsync(client, "withdraw", identity, origin: origin, fetchSite: fetchSite); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(Assert.Single(Receipts(identity)).WithdrawnAtUtc); + } + + [Fact] + public async Task Same_origin_by_fetch_metadata_alone_is_allowed() + { + var identity = NewIdentity(); + Seed(identity); + using var client = Client(); + + using var response = await PostAsync(client, "accept", identity, origin: null, fetchSite: "same-origin"); + + Assert.Equal(HttpStatusCode.SeeOther, response.StatusCode); + Assert.Single(Receipts(identity)); + } + + [Theory] + [InlineData("accept")] + [InlineData("withdraw")] + public async Task Anonymous_posts_are_unauthorized(string action) + { + using var client = Client(); + + using var response = await PostAsync(client, action, null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task One_account_changing_its_consent_leaves_every_other_account_untouched() + { + var mine = NewIdentity(); + var theirs = NewIdentity(); + Seed(mine, (TrainingNotice.Version, Agreed, null)); + Seed(theirs, (TrainingNotice.Version, Agreed, null)); + using var client = Client(); + + (await PostAsync(client, "withdraw", mine)).Dispose(); + (await PostAsync(client, "accept", mine)).Dispose(); + + Assert.Null(Assert.Single(Receipts(theirs)).WithdrawnAtUtc); + Assert.Equal(2, Receipts(mine).Count); + } + + [Fact] + public async Task A_hostile_identity_is_not_echoed_unescaped_on_the_page() + { + // The page shows no account name at all, so a hostile display name cannot reach it. + var identity = NewIdentity(); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = "" }); + db.SaveChanges(); + } + + var html = await PageHtmlAsync(identity); + + Assert.DoesNotContain(" x.WithdrawnAtUtc is null); + } +} diff --git a/tests/Bifrost.Web.Tests/DeskAnswerTests.cs b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs new file mode 100644 index 0000000..ce1bcdc --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskAnswerTests.cs @@ -0,0 +1,295 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskAnswerTests +{ + private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat"); + + private static async Task<(DeskWebApplicationFactory Factory, string Token)> ArrangeAsync() + { + var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + return (factory, factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer")); + } + + private static AnswerResult Stale(string forkId) => new( + DeskSchemas.AnswerResult, + new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", forkId, "Refused", null, null, "stale_fork", "oldhash", "no", null, + "2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator", + "00000001-0000-4000-8000-000000000001"), + null, + null); + + [Fact] + public async Task Stale_fork_attaches_current_card_fresh_and_replayed() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork)); + var target = DeskReplay.Target("answer", "gamecult-chat", null); + + factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "first card")); + var fresh = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + + // The same stored answer is replayed after the card changed: nothing is stored for the card, so the reply carries the new one. + factory.Door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork, "second card")); + var replayed = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + + Assert.Equal(409, fresh.Status); + Assert.Equal("first card", (string?)JsonNode.Parse(fresh.Body)!["card"]!["title"]); + Assert.Equal(409, replayed.Status); + Assert.Equal("second card", (string?)JsonNode.Parse(replayed.Body)!["card"]!["title"]); + } + } + + [Fact] + public async Task A_refusal_that_is_not_stale_carries_no_card_and_asks_no_read() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + var closed = Stale(DeskReplay.ChatFork); + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(closed with { Answer = closed.Answer with { Code = "fork_closed" } }); + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + + Assert.Equal(409, reply.Status); + Assert.Null(JsonNode.Parse(reply.Body)!["card"]); + Assert.DoesNotContain(factory.Door.Calls, c => c.Kind == "read"); + } + } + + [Fact] + public async Task A_stale_answers_card_read_of_an_oversize_fork_is_422_fork_too_large_naming_the_fork() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork)); + factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id); + + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + + var doc = JsonNode.Parse(reply.Body)!; + Assert.Equal(422, reply.Status); + Assert.Equal("fork_too_large", (string?)doc["code"]); + Assert.Contains(DeskReplay.ChatFork, (string)doc["error"]!); + Assert.Null(doc["card"]); + } + } + + [Fact] + public async Task A_replayed_request_id_that_is_stale_again_takes_the_same_reread_and_gets_the_same_422() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + // The door's stored Refused(stale_fork) for this request_id, returned on every replay while the fork stays oversize. + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork)); + factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id); + var body = DeskReplay.AnswerBody(DeskReplay.ChatFork); + var target = DeskReplay.Target("answer", "gamecult-chat", null); + + var first = await factory.SendAsync("POST", target, body, token); + var replay = await factory.SendAsync("POST", target, body, token); + + Assert.Equal(422, first.Status); + Assert.Equal(first.Status, replay.Status); + Assert.Equal(first.Body, replay.Body); + Assert.Equal("fork_too_large", (string?)JsonNode.Parse(replay.Body)!["code"]); + Assert.Contains(DeskReplay.ChatFork, (string)JsonNode.Parse(replay.Body)!["error"]!); + Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "answer")); + Assert.Equal(2, factory.Door.Calls.Count(c => c.Kind == "read")); + } + } + + [Fact] + public async Task A_stale_answer_over_an_oversize_fork_follows_the_vector_fresh_and_replayed() + { + var rows = DeskVectors.Rows("transport", "answerTooLarge").ToList(); + Assert.Single(rows); + foreach (var row in rows) + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + var request = row["request"]!; + var forkId = (string)row["fork_id"]!; + Assert.Equal(forkId, (string)request["fork_id"]!); + var stale = new AnswerResult( + DeskSchemas.AnswerResult, + new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", forkId, (string)row["outcome"]!, null, null, (string)row["code"]!, + (string)request["options_hash"]!, (string?)request["answer"]!["choice"], (string?)request["answer"]!["note"], + (string)request["answered_at"]!, "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator", + (string)request["request_id"]!), + null, + null); + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale); + factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id); + var body = DeskVectors.Bytes(request); + var target = DeskReplay.Target("answer", "gamecult-chat", null); + + var fresh = await factory.SendAsync("POST", target, body, token); + var replay = await factory.SendAsync("POST", target, body, token); + + foreach (var reply in new[] { fresh, replay }) + { + Assert.Equal((int)row["httpStatus"]!, reply.Status); + var doc = JsonNode.Parse(reply.Body); + Assert.True(DeskVectors.SameRefusal(doc, row["refusal"], forkId, null), (string)row["name"]!); + Assert.Null(doc!["card"]); + } + + Assert.Equal(fresh.Body, replay.Body); + // The door got the client's bytes untouched, stored nothing counted, and was asked for the card twice. + Assert.All(factory.Door.Calls.Where(c => c.Kind == "answer"), c => Assert.Equal(body, c.Body)); + Assert.Equal(forkId, factory.Door.Calls.First(c => c.Kind == "read").ForkId); + Assert.False((bool)row["counted"]!); + Assert.NotEqual("Counted", stale.Answer.Outcome); + } + } + } + + [Fact] + public async Task Card_read_failure_is_unavailable() + { + foreach (var failure in new[] { "throws", "refuses" }) + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(Stale(DeskReplay.ChatFork)); + factory.Door.ThrowOnRead = failure == "throws"; + // "refuses": no fork held, so the read answers fork_unknown + + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + + Assert.True(503 == reply.Status, failure); + Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]); + } + } + } + + [Fact] + public async Task The_body_reaches_the_door_byte_for_byte() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + DeskReplay.HoldVectorForks(factory.Door); + // Whitespace, escapes and key spacing a re-serialiser would normalise. + var compact = Encoding.UTF8.GetString(DeskReplay.AnswerBody(DeskReplay.ChatFork)); + var odd = compact.Replace("{", "{ \n\t", StringComparison.Ordinal).Replace(",\"", " ,\r\n \"", StringComparison.Ordinal) + .Replace("Keep", "\\u004beep", StringComparison.Ordinal) + " \n"; + var bytes = Encoding.UTF8.GetBytes(odd); + + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), bytes, token); + + Assert.Equal(200, reply.Status); + Assert.Equal(bytes, Assert.Single(factory.Door.Calls).Body); + } + } + + [Fact] + public async Task The_door_context_comes_from_the_account_and_the_configuration_never_the_request() + { + var (factory, _) = await ArrangeAsync(); + using (factory) + { + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Steward, true); + var token = factory.Token(DeskWebApplicationFactory.Steward.ToUpperInvariant(), "bifrost-desk", "desk.answer"); + + // Query strings and headers that name a decider or an answerer change nothing. + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "nibu", null) + "?deciders=" + DeskWebApplicationFactory.Member + "&answerer=x", + DeskReplay.AnswerBody("nibu:question:glow-order"), token, + configure: http => http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member); + + Assert.Equal(200, reply.Status); + var call = Assert.Single(factory.Door.Calls); + Assert.Equal(DeskWebApplicationFactory.Steward, call.Context.Subject); + Assert.Equal("Steward", call.Context.Authority); + Assert.Equal("nibu", call.Context.Mind); + Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), call.Context.Deciders); + } + } + + [Fact] + public async Task Body_is_judged_after_membership_and_before_the_mind() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + // A bad body for an unknown Mind is the body's refusal; a non-Active member's bad body is forbidden. + var bad = Encoding.UTF8.GetBytes("{\"answerer\":\"x\"}"); + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token); + Assert.Equal("body_forbidden_field", (string?)JsonNode.Parse(reply.Body)!["code"]); + + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false); + var forbidden = await factory.SendAsync("POST", DeskReplay.Target("answer", "ghost", null), bad, token); + Assert.Equal("forbidden", (string?)JsonNode.Parse(forbidden.Body)!["code"]); + Assert.Empty(factory.Door.Calls); + } + } + + [Fact] + public async Task Membership_is_re_read_on_every_request() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + DeskReplay.HoldVectorForks(factory.Door); + var target = DeskReplay.Target("answer", "gamecult-chat", null); + Assert.Equal(200, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, false); + Assert.Equal(403, (await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token)).Status); + } + } + + [Fact] + public async Task An_oversized_body_is_refused_before_it_is_read() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + var big = Encoding.UTF8.GetBytes("{\"x\":\"" + new string('a', 17 * 1024) + "\"}"); + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token); + Assert.Equal("body_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]); + + // Chunked: no Content-Length to judge, so the cap applies while reading. + var chunked = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), big, token, + configure: http => http.Request.ContentLength = null); + Assert.Equal("body_invalid", (string?)JsonNode.Parse(chunked.Body)!["code"]); + Assert.Empty(factory.Door.Calls); + } + } + + [Fact] + public async Task A_door_that_sends_a_code_the_contract_does_not_name_is_unavailable() + { + var (factory, token) = await ArrangeAsync(); + using (factory) + { + foreach (var script in new Func[] + { + () => new DeskAnswerOutcome.NotStored("something_internal"), + () => new DeskAnswerOutcome.NotStored("forbidden"), + () => new DeskAnswerOutcome.Answered(Stale("x") with { Answer = Stale("x").Answer with { Code = "made_up" } }), + }) + { + factory.Door.AnswerScript = (_, _, _) => script(); + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + Assert.Equal(503, reply.Status); + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs b/tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs new file mode 100644 index 0000000..bedbbfc --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskAuthCaseTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskAuthCaseTests +{ + [Fact] + public async Task Vector_auth_cases() + { + var cases = DeskVectors.Rows("transport", "authCases").ToList(); + Assert.Equal(22, cases.Count); + + foreach (var row in cases) + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + factory.Door.Down = (bool)row["huginn_down"]!; + var token = await DeskReplay.TokenAsync(factory, row["token"]); + + var route = (string)row["route"]!; + var reply = await factory.SendAsync( + route == "answer" ? "POST" : "GET", DeskReplay.Target(route, "gamecult-chat", DeskReplay.ChatFork), + route == "answer" ? DeskReplay.AnswerBody(DeskReplay.ChatFork) : null, token); + + var name = (string)row["name"]!; + var expect = (string)row["expect"]!; + var body = JsonNode.Parse(reply.Body); + switch (expect) + { + case "ok": + Assert.True(200 == reply.Status, $"{name}: {reply.Status} {reply.Text}"); + Assert.Single(factory.Door.Calls); + break; + case "unavailable": + Assert.True(503 == reply.Status, $"{name}: {reply.Status}"); + Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskUnavailables.Document(30), DeskJson.Options))), name); + break; + default: + Assert.True(DeskRefusals.StatusOf(expect) == reply.Status, $"{name}: {reply.Status}"); + Assert.True(DeskVectors.SameShape(body, JsonNode.Parse(System.Text.Json.JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))), name); + // Auth, scope and membership are judged before Huginn is asked. + Assert.True(factory.Door.Calls.Count == 0, $"{name}: the door was called"); + break; + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskBodyShapeTests.cs b/tests/Bifrost.Web.Tests/DeskBodyShapeTests.cs new file mode 100644 index 0000000..43dc1f5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskBodyShapeTests.cs @@ -0,0 +1,267 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskBodyShapeTests +{ + private const int Cap = 16 * 1024; + + private static DeskBodyVerdict Judge(JsonNode body, int cap = Cap) => DeskBodyShape.Judge(DeskVectors.Bytes(body), cap); + + private static DeskBodyVerdict Judge(string json, int cap = Cap) => DeskBodyShape.Judge(Encoding.UTF8.GetBytes(json), cap); + + private static JsonObject ValidBody() => + DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == "choice_and_note")["message"]!.DeepClone().AsObject(); + + private static string ExpectedCode(string name) => name switch + { + "answerer_key" or "authority_key" => DeskRefusals.BodyForbiddenField, + "answer_object_with_a_key" => DeskRefusals.BodyUnknownField, + _ => DeskRefusals.BodyInvalid, + }; + + public static IEnumerable ValidRows() => + DeskVectors.Rows("request", "valid").Select(row => new object[] { (string)row["name"]! }); + + public static IEnumerable InvalidRows() => + DeskVectors.Rows("request", "invalid").Select(row => new object[] { (string)row["name"]! }); + + [Theory] + [MemberData(nameof(ValidRows))] + public void Valid_request_vectors_pass(string name) + { + var message = DeskVectors.Rows("request", "valid").First(row => (string)row["name"]! == name)["message"]!; + + var verdict = Judge(message); + + Assert.Null(verdict.RefusalCode); + Assert.NotNull(verdict.Request); + // The parsed request is the document: same keys, same order, same values. + var echoed = JsonSerializer.SerializeToNode(verdict.Request, DeskJson.Options); + Assert.True(DeskVectors.SameShape(message, echoed), $"{name} did not round-trip: {echoed}"); + } + + [Theory] + [MemberData(nameof(InvalidRows))] + public void Invalid_request_vectors_refuse_with_their_code(string name) + { + var message = DeskVectors.Rows("request", "invalid").First(row => (string)row["name"]! == name)["message"]!; + + var verdict = Judge(message); + + Assert.Null(verdict.Request); + Assert.Equal(ExpectedCode(name), verdict.RefusalCode); + } + + [Fact] + public void Forbidden_key_at_any_depth_beats_unknown_key() + { + foreach (var key in new[] { "answerer", "authority" }) + { + var nested = ValidBody(); + nested["answer"]!["detail"] = new JsonObject { ["deep"] = new JsonArray(new JsonObject { [key] = "x" }) }; + nested["extra"] = 1; + Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(nested).RefusalCode); + + var top = ValidBody(); + top["unknown"] = 1; + top[key] = "someone"; + Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(top).RefusalCode); + + // Written with an escape: the name is the same once decoded, and still refused whole. + var escaped = DeskVectors.Bytes(ValidBody()).ToArray(); + var text = Encoding.UTF8.GetString(escaped).TrimEnd('}') + $",\"{key[..3]}\\u{(int)key[3]:x4}{key[4..]}\":\"x\"}}"; + Assert.Equal(DeskRefusals.BodyForbiddenField, Judge(text).RefusalCode); + } + + // The key name is matched exactly: a near miss is only an unknown key. + var near = ValidBody(); + near["Answerer"] = "x"; + Assert.Equal(DeskRefusals.BodyUnknownField, Judge(near).RefusalCode); + } + + [Fact] + public void Mind_and_deciders_in_the_body_are_unknown_fields() + { + foreach (var key in new[] { "mind", "deciders" }) + { + var body = ValidBody(); + body[key] = "x"; + Assert.Equal(DeskRefusals.BodyUnknownField, Judge(body).RefusalCode); + } + } + + [Fact] + public void Illegal_retraction_forms_reach_the_door() + { + var forms = new (string Answer, string Retracted, string Retracts)[] + { + ("null", "true", "null"), // a retraction with no target + ("{\"choice\":\"no\",\"note\":null}", "true", "\"n1\""), // a retraction carrying an answer + ("{\"choice\":\"no\",\"note\":null}", "false", "\"n1\""), // a plain answer with a target + ("null", "true", "\"n1\""), // the legal retraction + }; + + foreach (var (answer, retracted, retracts) in forms) + { + var body = ValidBody(); + body["answer"] = JsonNode.Parse(answer); + body["retracted"] = JsonNode.Parse(retracted); + body["retracts"] = JsonNode.Parse(retracts); + + var verdict = Judge(body); + + Assert.True(verdict.RefusalCode is null, $"answer={answer} retracted={retracted} retracts={retracts}: {verdict.RefusalCode}"); + Assert.NotNull(verdict.Request); + } + } + + [Fact] + public void Neither_answer_nor_retraction_is_body_invalid() + { + foreach (var retracts in new[] { "null", "\"n1\"" }) + { + var body = ValidBody(); + body["answer"] = null; + body["retracted"] = false; + body["retracts"] = JsonNode.Parse(retracts); + + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + } + } + + [Fact] + public void Note_limits_are_utf8_bytes() + { + string Note(string text) => Judge(WithNote(text)).RefusalCode ?? "pass"; + + string Repeat(string unit, int count) => string.Concat(Enumerable.Repeat(unit, count)); + var astral = char.ConvertFromUtf32(0x1F600); // 4 bytes + var combining = "e" + (char)0x0301; // 3 bytes + var twoByte = ((char)0xe9).ToString(); // 2 bytes + var spaces = new string(new[] { (char)9, (char)0xa0, (char)0x3000, (char)0x2028 }); + + Assert.Equal("pass", Note(new string('x', 4000))); + Assert.Equal(DeskRefusals.BodyInvalid, Note(new string('x', 4001))); + Assert.Equal("pass", Note(Repeat(astral, 1000))); + Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(astral, 1001))); + Assert.Equal("pass", Note(Repeat(combining, 1333) + "x")); + Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(combining, 1334))); + Assert.Equal(DeskRefusals.BodyInvalid, Note(Repeat(twoByte, 1999) + "xxx")); + Assert.Equal("pass", Note("x")); + Assert.Equal(DeskRefusals.BodyInvalid, Note("")); + Assert.Equal(DeskRefusals.BodyInvalid, Note(spaces)); + Assert.Equal("pass", Note(" a ")); + + static JsonNode WithNote(string text) + { + var body = ValidBody(); + body["answer"] = new JsonObject { ["choice"] = null, ["note"] = text }; + return body; + } + } + + [Fact] + public void Lone_surrogate_in_a_note_is_body_invalid() + { + var json = DeskVectors.Bytes(ValidBody()).ToArray(); + var text = Encoding.UTF8.GetString(json).Replace("\"note\":\"", "\"note\":\"\\ud800"); + Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode); + } + + [Fact] + public void Over_cap_refused_before_parse() + { + var body = ValidBody(); + var bytes = DeskVectors.Bytes(body); + + Assert.Null(DeskBodyShape.Judge(bytes, bytes.Length).RefusalCode); + Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bytes, bytes.Length - 1).RefusalCode); + + // Over the cap the forbidden key is never found, because the body is never parsed. + body["answerer"] = "x"; + var forbidden = DeskVectors.Bytes(body); + Assert.Equal(DeskRefusals.BodyForbiddenField, DeskBodyShape.Judge(forbidden, forbidden.Length).RefusalCode); + Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(forbidden, forbidden.Length - 1).RefusalCode); + } + + [Theory] + [InlineData("")] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("null")] + [InlineData("\"text\"")] + [InlineData("{")] + public void Non_object_or_broken_json_is_body_invalid(string json) => + Assert.Equal(DeskRefusals.BodyInvalid, Judge(json).RefusalCode); + + [Fact] + public void Invalid_utf8_is_body_invalid() + { + var bytes = DeskVectors.Bytes(ValidBody()); + var bad = bytes.Take(bytes.Length - 2).Concat(new byte[] { 0xff }).Concat(bytes.Skip(bytes.Length - 2)).ToArray(); + Assert.Equal(DeskRefusals.BodyInvalid, DeskBodyShape.Judge(bad, Cap).RefusalCode); + } + + [Fact] + public void Repeated_key_is_body_invalid() + { + var text = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).TrimEnd('}') + ",\"fork_id\":\"other\"}"; + Assert.Equal(DeskRefusals.BodyInvalid, Judge(text).RefusalCode); + + var answer = Encoding.UTF8.GetString(DeskVectors.Bytes(ValidBody())).Replace("\"choice\":\"no\"", "\"choice\":\"no\",\"choice\":\"yes\""); + Assert.Equal(DeskRefusals.BodyInvalid, Judge(answer).RefusalCode); + } + + [Fact] + public void Trailing_newline_does_not_rescue_a_bad_pattern() + { + var body = ValidBody(); + body["request_id"] = ((string)body["request_id"]!) + "\n"; + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + + body = ValidBody(); + body["options_hash"] = ((string)body["options_hash"]!) + "\n"; + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + } + + [Fact] + public void Wrong_schema_version_and_bad_retracts_are_body_invalid() + { + var body = ValidBody(); + body["schemaVersion"] = "gamecult.fork.answer_request.v1"; + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + + foreach (var retracts in new[] { "n0", "n01", "x1", "n" }) + { + body = ValidBody(); + body["answer"] = null; + body["retracted"] = true; + body["retracts"] = retracts; + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + } + } + + [Fact] + public void Missing_key_is_body_invalid_for_each_key() + { + foreach (var key in ValidBody().Select(p => p.Key).ToList()) + { + var body = ValidBody(); + body.Remove(key); + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + } + } + + [Fact] + public void Answer_object_must_hold_both_keys() + { + var body = ValidBody(); + body["answer"] = new JsonObject { ["choice"] = "no" }; + Assert.Equal(DeskRefusals.BodyInvalid, Judge(body).RefusalCode); + } +} 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 new file mode 100644 index 0000000..7551f1f --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskDocumentTests.cs @@ -0,0 +1,94 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskDocumentTests +{ + private static JsonNode? Write(T document) => JsonSerializer.SerializeToNode(document, DeskJson.Options); + + [Fact] + public void Refusal_and_unavailable_documents_match_vectors() + { + var rows = DeskVectors.Rows("doorRefusals").ToList(); + Assert.Equal(10, rows.Count); + foreach (var row in rows) + { + var code = (string)row["message"]!["code"]!; + if (code == DeskRefusals.ForkTooLarge) + { + // It names its fork, so it is built, not looked up; the row is the list form at order 3. + Assert.True(DeskVectors.SameRefusal(Write(DeskRefusals.ForkTooLargeDocument("gamecult-chat:question:q3", 3)), row["message"], "gamecult-chat:question:q3", 3), $"refusal {code}"); + Assert.Equal((int)row["httpStatus"]!, DeskRefusals.ForkTooLargeStatus); + continue; + } + + Assert.True(DeskVectors.SameShape(row["message"], Write(DeskRefusals.Document(code))), $"refusal {code}"); + Assert.Equal((int)row["httpStatus"]!, DeskRefusals.StatusOf(code)); + } + + var unavailable = DeskVectors.Rows("unavailable").ToList(); + Assert.Equal(2, unavailable.Count); + foreach (var row in unavailable) + { + var retry = (int?)row["message"]!["retryAfterSeconds"]; + Assert.True(DeskVectors.SameShape(row["message"], Write(DeskUnavailables.Document(retry))), (string)row["name"]!); + Assert.Equal(DeskUnavailables.Status, (int)row["httpStatus"]!); + } + } + + [Fact] + public void Every_refusal_code_in_the_schema_has_a_document() + { + var codes = DeskVectors.Rows("doorRefusals").Select(row => (string)row["message"]!["code"]!).ToHashSet(); + var written = typeof(DeskRefusals).GetFields().Where(f => f.IsLiteral).Select(f => (string)f.GetRawConstantValue()!).ToHashSet(); + Assert.Equal(codes, written); + } + + [Fact] + public void Answer_results_round_trip_with_their_spelling() + { + var rows = DeskVectors.Rows("results").ToList(); + Assert.Equal(6, rows.Count); + foreach (var row in rows) + { + var message = row["message"]!; + var result = message.Deserialize(DeskJson.Options)!; + Assert.True(DeskVectors.SameShape(message, Write(result)), (string)row["name"]!); + } + } + + [Fact] + public void Cards_round_trip_with_their_spelling() + { + var rows = DeskVectors.Rows("card", "valid").ToList(); + Assert.Equal(7, rows.Count); + foreach (var row in rows) + { + var message = row["message"]!; + 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/DeskHardeningTests.cs b/tests/Bifrost.Web.Tests/DeskHardeningTests.cs new file mode 100644 index 0000000..c7b0055 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskHardeningTests.cs @@ -0,0 +1,327 @@ +using System.Text; +using System.Text.Json.Nodes; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +// The rules the desk's edge keeps that a happy path never shows: whose decider list rides each call, the body cap +// to the byte, what the Retry-After comes from, the audience's case, no-store, and what a failing port becomes. +public sealed class DeskHardeningTests +{ + private const string Nibu = "nibu"; + private const string Chat = "gamecult-chat"; + private const string NibuFork = "nibu:question:glow-order"; + + private static async Task ArrangeAsync(Dictionary? configuration = null) + { + var factory = new DeskWebApplicationFactory(configuration); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + return factory; + } + + private static string Token(DeskWebApplicationFactory factory, string audience = "bifrost-desk") => + factory.Token(DeskWebApplicationFactory.Alice, audience, "desk.read", "desk.answer"); + + private static Task SendRouteAsync(DeskWebApplicationFactory factory, string route, string mind, string token) + { + var fork = mind == Nibu ? NibuFork : DeskReplay.ChatFork; + return route switch + { + "answer" => factory.SendAsync("POST", DeskReplay.Target("answer", mind, null), DeskReplay.AnswerBody(fork), token), + "read" => factory.SendAsync("GET", DeskReplay.Target("read", mind, fork), null, token), + _ => factory.SendAsync("GET", DeskReplay.Target("list", mind, null), null, token), + }; + } + + private static readonly string[] Routes = ["answer", "read", "list"]; + + private static string Code(DeskReply reply) => (string)JsonNode.Parse(reply.Body)!["code"]!; + + // (a) Each Mind's list is its own: a context built from another Mind's list changes the call. + [Fact] + public async Task Each_call_carries_the_route_minds_own_decider_list_and_envelope() + { + using var factory = await ArrangeAsync(); + Assert.NotEqual(DeskWebApplicationFactory.DecidersOf(Chat), DeskWebApplicationFactory.DecidersOf(Nibu)); + var token = Token(factory); + + foreach (var mind in new[] { Chat, Nibu }) + { + foreach (var route in Routes) + { + factory.Door.Calls.Clear(); + var reply = await SendRouteAsync(factory, route, mind, token); + Assert.True(reply.Status == 200, $"{mind} {route}: {reply.Status}"); + var call = Assert.Single(factory.Door.Calls); + Assert.True(DeskWebApplicationFactory.DecidersOf(mind).SequenceEqual(call.Context.Deciders), $"{mind} {route}: deciders"); + Assert.Equal(mind, call.Context.Mind); + Assert.Equal(DeskWebApplicationFactory.SocketOf(mind), call.Socket); + Assert.Equal(DeskWebApplicationFactory.Alice, call.Context.Subject); + Assert.Equal("Operator", call.Context.Authority); + } + } + } + + // (b) The cap to the byte, at the HTTP edge, however the length is announced. + [Theory] + [InlineData(0)] + [InlineData(4096)] + [InlineData(4097)] + [InlineData(8192)] + public async Task A_body_of_exactly_the_cap_reaches_the_shape_judge_and_one_more_byte_does_not(int cap) + { + var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork); + cap = Math.Max(cap, bare.Length); + var config = DeskWebApplicationFactory.DefaultConfiguration(); + config["Desk:MaxBodyBytes"] = cap.ToString(System.Globalization.CultureInfo.InvariantCulture); + using var factory = await ArrangeAsync(config); + var token = Token(factory); + var target = DeskReplay.Target("answer", Chat, null); + byte[] Padded(int length) => [.. bare, .. Enumerable.Repeat((byte)' ', length - bare.Length)]; + + var modes = new (string Name, Action Configure)[] + { + ("content-length", _ => { }), + ("no content-length", http => http.Request.ContentLength = null), + ("chunked", http => + { + http.Request.ContentLength = null; + http.Request.Headers.TransferEncoding = "chunked"; + }), + }; + foreach (var (name, configure) in modes) + { + factory.Door.Calls.Clear(); + var exact = await factory.SendAsync("POST", target, Padded(cap), token, configure: configure); + Assert.True(exact.Status == 200, $"{name} exact: {exact.Status} {exact.Text}"); + Assert.Equal(cap, Assert.Single(factory.Door.Calls).Body!.Length); + + factory.Door.Calls.Clear(); + var over = await factory.SendAsync("POST", target, Padded(cap + 1), token, configure: configure); + Assert.True(over.Status == 400 && Code(over) == "body_invalid", $"{name} over: {over.Status} {over.Text}"); + Assert.Empty(factory.Door.Calls); + } + } + + // A declared length over the cap is refused on the header alone, before any byte is read. + [Fact] + public async Task A_declared_length_over_the_cap_is_refused_before_reading() + { + var bare = DeskReplay.AnswerBody(DeskReplay.ChatFork); + var config = DeskWebApplicationFactory.DefaultConfiguration(); + config["Desk:MaxBodyBytes"] = bare.Length.ToString(System.Globalization.CultureInfo.InvariantCulture); + using var factory = await ArrangeAsync(config); + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", Chat, null), bare, Token(factory), + configure: http => http.Request.ContentLength = bare.Length + 1); + + Assert.Equal("body_invalid", Code(reply)); + Assert.Empty(factory.Door.Calls); + } + + // (c) The wait an `unavailable` names is the configured one, in the document and in the header. + [Fact] + public async Task Retry_after_comes_from_configuration_in_the_document_and_the_header() + { + var config = DeskWebApplicationFactory.DefaultConfiguration(); + config["Desk:RetryAfterSeconds"] = "7"; + using var factory = await ArrangeAsync(config); + factory.Door.Down = true; + var token = Token(factory); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, token); + Assert.True(reply.Status == 503, route); + Assert.Equal(7, (int)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]!); + Assert.Equal("7", reply.Headers.RetryAfter.ToString()); + } + } + + // (d) The audience is compared ordinally. + [Theory] + [InlineData("BIFROST-DESK")] + [InlineData("Bifrost-Desk")] + [InlineData("bifrost-desk ")] + [InlineData("bifrost-votes")] + public async Task The_audience_is_case_sensitive(string audience) + { + using var factory = await ArrangeAsync(); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory, audience)); + Assert.True(reply.Status == 401 && Code(reply) == "unauthenticated", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + // (e) No desk response is cacheable: answers, refusals, unavailable and an unrecognised target alike. + [Fact] + public async Task Every_desk_response_is_no_store() + { + using var factory = await ArrangeAsync(); + var token = Token(factory); + var replies = new List<(string, DeskReply)>(); + foreach (var route in Routes) + { + replies.Add((route + " 200", await SendRouteAsync(factory, route, Chat, token))); + replies.Add((route + " 401", await SendRouteAsync(factory, route, Chat, ""))); + replies.Add((route + " 404", await SendRouteAsync(factory, route, "ghost", token))); + } + + factory.Door.Down = true; + replies.Add(("503", await SendRouteAsync(factory, "list", Chat, token))); + replies.Add(("unrecognised", await factory.SendAsync("GET", "/api/desk/x", null, token))); + + foreach (var (label, reply) in replies) + { + Assert.True(reply.Headers.CacheControl.ToString() == "no-store", $"{label}: '{reply.Headers.CacheControl}'"); + } + + Assert.Equal(200, replies[0].Item2.Status); + Assert.Equal(503, replies[^2].Item2.Status); + } + + // (f) A cancelled request is not `unavailable`: the exception goes on to the host. + [Fact] + public async Task A_cancelled_request_is_not_called_unavailable() + { + using var factory = await ArrangeAsync(); + using var cts = new CancellationTokenSource(); + factory.Door.AnswerScript = (_, _, _) => + { + cts.Cancel(); + throw new IOException("the client went away"); + }; + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", Chat, null), DeskReplay.AnswerBody(DeskReplay.ChatFork), Token(factory), + configure: http => http.RequestAborted = cts.Token); + + // The host's exception handling answers; the desk does not claim the door was merely unavailable. + Assert.NotEqual(503, reply.Status); + Assert.DoesNotContain("unavailable", reply.Text); + } + + // (f) A stale_fork needs the current card; a card read that finds nothing, for any code, is `unavailable`. + [Theory] + [InlineData("fork_unknown")] + [InlineData("request_invalid")] + [InlineData("made_up")] + public async Task A_stale_fork_whose_card_read_finds_nothing_is_unavailable_never_a_stale_fork_without_a_card(string code) + { + using var factory = await ArrangeAsync(); + var stale = new AnswerResult( + DeskSchemas.AnswerResult, + new DeskAnswerDocument( + DeskSchemas.DeskAnswer, "n1", DeskReplay.ChatFork, "Refused", null, null, "stale_fork", "oldhash", "no", null, + "2026-11-12T19:02:00Z", "2026-11-12T19:02:05Z", false, DeskWebApplicationFactory.Alice, "Operator", + "00000001-0000-4000-8000-000000000001"), + null, + null); + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(stale); + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code); + + var reply = await SendRouteAsync(factory, "answer", Chat, Token(factory)); + + Assert.Equal(503, reply.Status); + Assert.Equal("unavailable", Code(reply)); + Assert.Null(JsonNode.Parse(reply.Body)!["card"]); + Assert.Contains(factory.Door.Calls, c => c.Kind == "read"); + } + + // (h) A port that fails is `unavailable`, never a 500 and never a pass. + [Fact] + public async Task A_throwing_credential_port_is_unavailable_and_opens_nothing() + { + using var factory = await ArrangeAsync(); + factory.Credential.Throws = true; + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task A_throwing_account_lookup_is_unavailable_and_opens_nothing() + { + using var factory = await ArrangeAsync(); + // The request's database context is gone by the time the desk looks the account up (the database failed). + factory.Credential.OnAuthenticate = http => http.RequestServices.GetRequiredService().Dispose(); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + // The runtime guard on the decider list (the startup validator is the first layer; this is the second). + private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D"); + + private static void SetDeciders(DeskWebApplicationFactory factory, string mind, IEnumerable ids) + { + var list = factory.Services.GetRequiredService>().Value.Minds.Single(m => m.Id == mind).Deciders; + list.Clear(); + list.AddRange(ids); + } + + [Fact] + public async Task The_door_is_called_with_64_deciders() + { + using var factory = await ArrangeAsync(); + var ids = Enumerable.Range(1, 64).Select(Id).ToList(); + SetDeciders(factory, Chat, ids); + + foreach (var route in Routes) + { + factory.Door.Calls.Clear(); + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 200, route); + Assert.Equal(ids, Assert.Single(factory.Door.Calls).Context.Deciders); + } + } + + [Theory] + [InlineData("65")] + [InlineData("0")] + [InlineData("duplicate")] + public async Task A_decider_list_out_of_bounds_is_unavailable_without_a_door_call(string fault) + { + using var factory = await ArrangeAsync(); + var ids = fault switch + { + "65" => Enumerable.Range(1, 65).Select(Id).ToList(), + "0" => [], + _ => [Id(1), Id(2), Id(1)], + }; + SetDeciders(factory, Chat, ids); + + foreach (var route in Routes) + { + var reply = await SendRouteAsync(factory, route, Chat, Token(factory)); + Assert.True(reply.Status == 503 && Code(reply) == "unavailable", $"{route}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + + // Only that Mind's list is at fault: the other Mind still answers. + var other = await SendRouteAsync(factory, "list", Nibu, Token(factory)); + Assert.Equal(200, other.Status); + } +} diff --git a/tests/Bifrost.Web.Tests/DeskMindRouteTests.cs b/tests/Bifrost.Web.Tests/DeskMindRouteTests.cs new file mode 100644 index 0000000..86b1835 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskMindRouteTests.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskMindRouteTests +{ + private static async Task<(DeskReply Reply, DeskWebApplicationFactory Factory)> RunAsync(JsonObject row) + { + var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + var token = await DeskReplay.TokenAsync(factory, row["token"]); + var route = (string)row["route"]!; + var forkId = (string?)row["fork_id"]; + var reply = await factory.SendAsync( + route == "answer" ? "POST" : "GET", + DeskReplay.Target(route, (string)row["mind"]!, forkId), + route == "answer" ? DeskReplay.AnswerBody(forkId!) : null, + token); + return (reply, factory); + } + + [Fact] + public async Task Vector_route_cases() + { + var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList(); + Assert.Equal(57, cases.Count); + + foreach (var row in cases) + { + var name = (string)row["name"]!; + var (reply, factory) = await RunAsync(row); + using (factory) + { + Assert.True((int)row["httpStatus"]! == reply.Status, $"{name}: {reply.Status} {reply.Text}"); + var expect = (string)row["expect"]!; + if (expect != "ok") + { + Assert.True( + DeskVectors.SameShape(JsonNode.Parse(reply.Body), JsonNode.Parse(JsonSerializer.Serialize(DeskRefusals.Document(expect), DeskJson.Options))), + name); + } + + var opened = (string?)row["opened"]; + if (opened is null) + { + Assert.True(factory.Door.Calls.Count == 0, $"{name}: opened {factory.Door.Calls.FirstOrDefault()?.Socket}"); + } + else + { + var call = Assert.Single(factory.Door.Calls); + Assert.True(call.Socket.EndsWith("/" + opened, StringComparison.Ordinal), $"{name}: {call.Socket}"); + } + + var storedIn = (string?)row["stored_in"]; + if (storedIn is null) + { + Assert.Empty(factory.Door.StoredIn); + } + else + { + Assert.Equal([DeskWebApplicationFactory.SocketOf(storedIn)], factory.Door.StoredIn); + } + } + } + } + + [Fact] + public async Task Same_response_pairs_are_byte_equal() + { + var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToDictionary(r => (string)r["name"]!); + var pairs = DeskVectors.Array("transport", "mindRoutes", "sameResponse"); + Assert.Equal(4, pairs.Count); + + foreach (var pair in pairs) + { + var first = cases[(string)pair![0]!]; + var second = cases[(string)pair[1]!]; + var (a, fa) = await RunAsync(first); + var (b, fb) = await RunAsync(second); + using (fa) + using (fb) + { + Assert.Equal(a.Status, b.Status); + Assert.Equal(a.Body, b.Body); + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/DeskMindRoutesTests.cs b/tests/Bifrost.Web.Tests/DeskMindRoutesTests.cs new file mode 100644 index 0000000..f8d9604 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskMindRoutesTests.cs @@ -0,0 +1,125 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskMindRoutesTests +{ + private static DeskMindRoutes Routes() + { + var minds = DeskVectors.Root["transport"]!["mindRoutes"]!["minds"]!.AsObject(); + return new DeskMindRoutes(minds.Select(m => new DeskMindOptions { Id = m.Key, Socket = "/run/huginn/" + (string)m.Value! })); + } + + [Fact] + public void Every_vector_segment_resolves_or_misses() + { + var routes = Routes(); + var cases = DeskVectors.Rows("transport", "mindRoutes", "cases").ToList(); + Assert.Equal(57, cases.Count); + + foreach (var row in cases) + { + var mind = (string)row["mind"]!; + var opened = (string?)row["opened"]; + var resolved = routes.Resolve(mind); + + // A route that opens a socket resolves to the Mind whose socket it is; every other route is a miss. + // Auth-refused cases never reach the lookup, so only the cases that reach it are judged here. + var expect = (string)row["expect"]!; + if (expect is not ("ok" or "fork_unknown" or "unavailable")) + { + continue; + } + + if (opened is null) + { + Assert.True(resolved is null, $"{row["name"]}: '{Short(mind)}' resolved to '{resolved?.Id}'"); + } + else + { + Assert.True(resolved is not null, $"{row["name"]}: '{Short(mind)}' missed"); + Assert.EndsWith(opened, resolved!.Socket, StringComparison.Ordinal); + } + } + } + + [Fact] + public void Segments_that_do_not_name_a_configured_mind_miss() + { + var routes = Routes(); + foreach (var mind in new[] + { + "ghost", ".", "..", "../x", "a/b", "a%2fb", "%2e%2e%2fx", "eureka%00", "nibu%20", "nibu ", " nibu", + "%ff", "%c3%28", "%", "%6", "%zz", "nibu%", "%256eibu", "", new string('a', 4096), "nïbu", + }) + { + Assert.Null(routes.Resolve(mind)); + } + } + + [Fact] + public void Percent_encoded_configured_ids_are_decoded_once() + { + var routes = Routes(); + + Assert.Equal("nibu", routes.Resolve("%6eibu")!.Id); + Assert.Equal("nibu", routes.Resolve("%6Eibu")!.Id); + Assert.Equal("gamecult-chat", routes.Resolve("gamecult%2Dchat")!.Id); + } + + [Fact] + public void Other_case_misses_despite_config_case_insensitivity() + { + var routes = Routes(); + + Assert.Null(routes.Resolve("GAMECULT-CHAT")); + Assert.Null(routes.Resolve("Nibu")); + Assert.Null(routes.Resolve("gamecult-chat%20")); + Assert.NotNull(routes.Resolve("gamecult-chat")); + } + + [Fact] + public void Double_encoded_misses() + { + var routes = Routes(); + + Assert.Null(routes.Resolve("%256eibu")); + Assert.Null(routes.Resolve("gamecult%252Dchat")); + } + + [Fact] + public void Configured_ids_that_differ_only_by_case_stay_distinct() + { + var routes = new DeskMindRoutes([ + new DeskMindOptions { Id = "a", Socket = "/s/a" }, + new DeskMindOptions { Id = "A", Socket = "/s/A" }, + ]); + + Assert.Equal("/s/a", routes.Resolve("a")!.Socket); + Assert.Equal("/s/A", routes.Resolve("A")!.Socket); + } + + [Fact] + public void A_decoding_failure_is_a_miss_even_when_the_failure_spells_a_configured_id() + { + var replacement = char.ConvertFromUtf32(0xFFFD); + var e = char.ConvertFromUtf32(0xE9); + var routes = new DeskMindRoutes([ + new DeskMindOptions { Id = replacement, Socket = "/s/replacement" }, + new DeskMindOptions { Id = e, Socket = "/s/e-acute" }, + new DeskMindOptions { Id = "a%zz", Socket = "/s/bad-escape" }, + new DeskMindOptions { Id = "100%", Socket = "/s/trailing" }, + ]); + + Assert.Null(routes.Resolve("%ff")); // not UTF-8, not U+FFFD + Assert.Null(routes.Resolve(char.ConvertFromUtf32(0xC3) + char.ConvertFromUtf32(0xA9))); // UTF-8 bytes as raw chars + Assert.Null(routes.Resolve("a%zz")); + Assert.Null(routes.Resolve("100%")); + Assert.Equal("/s/replacement", routes.Resolve("%ef%bf%bd")!.Socket); // the real encoding still resolves + Assert.Equal("/s/e-acute", routes.Resolve("%c3%a9")!.Socket); + } + + private static string Short(string value) => value.Length > 40 ? value[..40] + "..." : value; +} diff --git a/tests/Bifrost.Web.Tests/DeskOptionsTests.cs b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs new file mode 100644 index 0000000..53863b6 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskOptionsTests.cs @@ -0,0 +1,245 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Features.Shared; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class DeskOptionsTests +{ + private const string Alice = "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f"; + private const string Bob = "00000000-0000-4000-8000-000000000002"; + + private static Dictionary Good() => new() + { + ["Desk:AllowedOrigin"] = "https://chat.gamecult.org", + ["Desk:Minds:0:Id"] = "gamecult-chat", + ["Desk:Minds:0:Socket"] = "/run/huginn/huginn-gamecult-chat.sock", + ["Desk:Minds:0:Deciders:0"] = Alice, + ["Desk:Minds:1:Id"] = "nibu", + ["Desk:Minds:1:Socket"] = "/run/huginn/huginn-nibu.sock", + ["Desk:Minds:1:Deciders:0"] = Bob, + ["Desk:Attribution:Operator"] = Alice, + ["Desk:Attribution:Stewards:0"] = Bob, + }; + + private static DeskOptions Bind(Dictionary values) + { + var options = new DeskOptions(); + new ConfigurationBuilder().AddInMemoryCollection(values).Build().GetSection(DeskOptions.SectionName).Bind(options); + return options; + } + + private static void Validate(Dictionary values, string environment = "Testing", bool oidc = true) + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + new StartupConfigurationValidator( + configuration, + new StubEnvironment(environment), + Options.Create(new GitHubOAuthOptions()), + Options.Create(new GitHubAppOptions()), + Options.Create(new HeimdallOptions()), + Options.Create(new StripeOptions()), + Options.Create(new BifrostHostOptions()), + Options.Create(new ConceptVoteOptions()), + Options.Create(Bind(values)), + Options.Create(new RegistrationOptions()), + oidc ? new OidcIssuerOptions() : null).Validate(); + } + + private static void Refused(Dictionary values, string expectedFragment) + { + var failure = Assert.Throws(() => Validate(values)); + Assert.Contains(expectedFragment, failure.Message); + } + + [Fact] + public void A_good_desk_configuration_binds_by_position_and_passes() + { + var options = Bind(Good()); + + Assert.Equal(["gamecult-chat", "nibu"], options.Minds.Select(m => m.Id)); + Assert.Equal([Alice], options.Minds[0].Deciders); + Assert.Equal([Bob], options.Minds[1].Deciders); + 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() + { + Assert.False(Bind([]).IsConfigured); + Validate([]); + } + + [Fact] + public void Mind_ids_keep_the_case_the_configuration_gave_them() + { + var values = Good(); + values["Desk:Minds:0:Id"] = "GameCult-Chat"; + + Assert.Equal("GameCult-Chat", Bind(values).Minds[0].Id); + } + + [Fact] + public void Startup_refuses_duplicate_ids() => Refused(Dup(), "Desk:Minds:1:Id repeats"); + + private static Dictionary Dup() + { + var values = Good(); + values["Desk:Minds:1:Id"] = "gamecult-chat"; + return values; + } + + [Fact] + public void Startup_refuses_a_blank_id() + { + var values = Good(); + values["Desk:Minds:1:Id"] = " "; + + Refused(values, "Desk:Minds:1:Id must not be blank"); + } + + [Theory] + [InlineData("huginn.sock")] + [InlineData("run/huginn.sock")] + [InlineData("")] + public void Startup_refuses_relative_socket(string socket) + { + var values = Good(); + values["Desk:Minds:1:Socket"] = socket; + + Refused(values, "Desk:Minds:1:Socket must be an absolute path"); + } + + [Theory] + [InlineData("E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F")] + [InlineData("e0d1c2b3a495468788796a5b4c3d2e1f")] + [InlineData("{e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f}")] + [InlineData("not-a-guid")] + [InlineData("")] + public void Startup_refuses_bad_decider_id(string decider) + { + var values = Good(); + values["Desk:Minds:0:Deciders:1"] = decider; + + Refused(values, "Desk:Minds:0:Deciders entries must be lowercase D-format"); + } + + [Fact] + public void Startup_refuses_a_repeated_decider() + { + var values = Good(); + values["Desk:Minds:0:Deciders:1"] = Alice; + + Refused(values, "Desk:Minds:0:Deciders repeats"); + } + + [Fact] + public void Startup_refuses_operator_in_stewards() + { + var values = Good(); + values["Desk:Attribution:Stewards:1"] = Alice; + + Refused(values, "must not also be a steward"); + } + + [Fact] + public void Startup_refuses_bad_or_repeated_attribution_ids() + { + var bad = Good(); + bad["Desk:Attribution:Operator"] = "ALICE"; + Refused(bad, "Desk:Attribution:Operator must be a lowercase"); + + var badSteward = Good(); + badSteward["Desk:Attribution:Stewards:1"] = "E0D1C2B3-A495-4687-8879-6A5B4C3D2E1F"; + Refused(badSteward, "Desk:Attribution:Stewards entries must be lowercase"); + + var repeated = Good(); + repeated["Desk:Attribution:Stewards:1"] = Bob; + Refused(repeated, "Desk:Attribution:Stewards repeats"); + } + + private static string Id(int n) => new Guid(n, 0, 0, new byte[8]).ToString("D"); + + private static Dictionary WithDeciders(int count) + { + var values = Good(); + for (var i = 0; i < count; i++) + { + values[$"Desk:Minds:0:Deciders:{i}"] = Id(i + 1); + } + + if (count == 0) + { + values.Remove("Desk:Minds:0:Deciders:0"); + } + + return values; + } + + [Fact] + public void Startup_takes_one_to_64_deciders_per_mind() + { + Validate(WithDeciders(1)); + Validate(WithDeciders(64)); + Refused(WithDeciders(65), "Desk:Minds:0:Deciders must hold 1 to 64"); + Refused(WithDeciders(0), "Desk:Minds:0:Deciders must hold 1 to 64"); + + // The bound is per Mind: the second Mind is judged on its own list. + var second = Good(); + second.Remove("Desk:Minds:1:Deciders:0"); + Refused(second, "Desk:Minds:1:Deciders must hold 1 to 64"); + } + + [Fact] + public void Startup_refuses_two_minds_sharing_one_socket() + { + var values = Good(); + values["Desk:Minds:1:Socket"] = values["Desk:Minds:0:Socket"]; + + Refused(values, "Desk:Minds:1:Socket repeats an earlier Mind's socket"); + } + + [Fact] + public void Desk_failures_are_reported_in_production_too_and_alongside_each_other() + { + var values = Good(); + values["Desk:Minds:1:Socket"] = "relative"; + values["Desk:Attribution:Stewards:1"] = Alice; + + var failure = Assert.Throws(() => Validate(values, "Production")); + Assert.Contains("Desk:Minds:1:Socket", failure.Message); + Assert.Contains("must not also be a steward", failure.Message); + } + + [Fact] + public void Attribution_is_operator_steward_member() + { + var attribution = Bind(Good()).Attribution; + + Assert.Equal("Operator", attribution.AuthorityOf(Alice)); + Assert.Equal("Steward", attribution.AuthorityOf(Bob)); + Assert.Equal("Member", attribution.AuthorityOf("00000000-0000-4000-8000-000000000003")); + Assert.Equal("Member", attribution.AuthorityOf(Alice.ToUpperInvariant())); + Assert.Equal("Member", new DeskAttributionOptions().AuthorityOf(string.Empty)); + } + + private sealed class StubEnvironment(string name) : IHostEnvironment + { + public string EnvironmentName { get; set; } = name; + public string ApplicationName { get; set; } = "Bifrost.Web"; + public string ContentRootPath { get; set; } = string.Empty; + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!; + } +} diff --git a/tests/Bifrost.Web.Tests/DeskReadTests.cs b/tests/Bifrost.Web.Tests/DeskReadTests.cs new file mode 100644 index 0000000..027d7da --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskReadTests.cs @@ -0,0 +1,467 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskReadTests +{ + private static string ReadToken(DeskWebApplicationFactory f) => + f.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.read"); + + private static Uri Unused => new("http://unused"); + + // A list_request invalid row names the query that cannot express it; the others are the shapes a query cannot hold. + private static readonly Dictionary InvalidListQueries = new() + { + ["limit_51"] = "?limit=51", + ["limit_negative"] = "?limit=-1", + ["status_all"] = "?status=all", + ["cursor_malformed"] = "?cursor=next", + ["cursor_zero"] = "?cursor=c0", + ["unknown_key"] = "?sort=oldest", + }; + + [Fact] + public async Task List_and_read_request_invalid_vectors() + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var token = ReadToken(factory); + + var invalid = DeskVectors.Rows("transport", "listRequest", "invalid").Select(r => (string)r["name"]!).ToList(); + foreach (var name in invalid.Where(n => n != "mind_empty")) + { + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + InvalidListQueries[name], null, token); + Assert.True(reply.Status == 400, $"{name}: {reply.Status}"); + Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]); + } + + // Also: repeated key, bad escape, query on a read, empty fork id. + foreach (var target in new[] + { + "/api/desk/gamecult-chat/forks?limit=1&limit=2", "/api/desk/gamecult-chat/forks?limit=%zz", "/api/desk/gamecult-chat/forks?limit=", + "/api/desk/gamecult-chat/forks?limit=+1", "/api/desk/gamecult-chat/forks?limit=1.0", + "/api/desk/gamecult-chat/forks/gamecult-chat%3Aquestion%3Aq1?x=1", "/api/desk/gamecult-chat/forks/", "/api/desk/gamecult-chat/forks/%ff", + }) + { + var reply = await factory.SendAsync("GET", target, null, token); + Assert.True(reply.Status == 400, $"{target}: {reply.Status}"); + } + + // The invalid request is judged before the Mind: an unknown Mind with a bad query is the query's refusal. + var order = await factory.SendAsync("GET", "/api/desk/ghost/forks?limit=99", null, token); + Assert.Equal("request_invalid", (string?)JsonNode.Parse(order.Body)!["code"]); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task Valid_list_queries_reach_the_door_as_typed_requests() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + foreach (var row in DeskVectors.Rows("transport", "listRequest", "valid")) + { + var m = row["message"]!; + var query = $"?status={(string?)m["status"]}&cursor={(string?)m["cursor"]}&limit={(int)m["limit"]!}"; + var reply = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks" + query, null, ReadToken(factory)); + Assert.Equal(200, reply.Status); + var call = factory.Door.Calls[^1]; + Assert.Equal(new DeskListRequest("gamecult-chat", (string?)m["status"], (string?)m["cursor"], (int)m["limit"]!), call.List); + } + + var bare = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory)); + Assert.Equal(new DeskListRequest("nibu", null, null, 50), factory.Door.Calls[^1].List); + Assert.Equal(200, bare.Status); + } + + [Fact] + public async Task Read_returns_the_entry_unchanged_and_an_unknown_fork_is_refused() + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + var ok = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, ReadToken(factory)); + var entry = JsonSerializer.SerializeToNode(DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)), DeskJson.Options); + var missing = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", "gamecult-chat:question:ghost"), null, ReadToken(factory)); + + Assert.Equal(200, ok.Status); + Assert.True(DeskVectors.SameShape(JsonNode.Parse(ok.Body), entry)); + Assert.Equal(DeskReplay.ChatFork, Assert.IsType(factory.Door.Calls[0].ForkId)); + Assert.Equal(404, missing.Status); + } + + [Fact] + public async Task A_fork_too_large_for_a_page_is_a_422_that_names_the_fork_and_its_order() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:huge", 7); + var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory)); + var doc = JsonNode.Parse(reply.Body)!; + Assert.Equal(422, reply.Status); + Assert.Equal("fork_too_large", (string?)doc["code"]); + var error = (string)doc["error"]!; + Assert.Contains("nibu:question:huge", error); + Assert.Contains("order 7", error); + + // At order 0 the text still names the fork and its order: the client reads "below 0" as the end. + factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge("nibu:question:first", 0); + var end = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, ReadToken(factory)); + var endError = (string)JsonNode.Parse(end.Body)!["error"]!; + Assert.Equal(422, end.Status); + Assert.Contains("nibu:question:first", endError); + Assert.Contains("order 0", endError); + } + + [Fact] + public async Task fork_too_large_as_a_bare_door_code_is_unavailable_only_the_typed_outcomes_are_the_refusal() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound("fork_too_large"); + factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed("fork_too_large"); + foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") }) + { + var reply = await factory.SendAsync("GET", target, null, ReadToken(factory)); + Assert.Equal(503, reply.Status); + } + } + + [Fact] + public async Task A_read_of_an_oversize_fork_is_the_same_422_naming_the_fork_with_no_order_or_cursor() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id); + + var reply = await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:huge"), null, ReadToken(factory)); + + var doc = JsonNode.Parse(reply.Body)!; + Assert.Equal(422, reply.Status); + Assert.Equal("fork_too_large", (string?)doc["code"]); + var error = (string)doc["error"]!; + Assert.Contains("nibu:question:huge", error); + Assert.DoesNotContain("order", error); + Assert.DoesNotContain("cursor", error); + } + + [Fact] + public async Task List_short_pages_and_oversize_forks_follow_the_vectors() + { + var steps = 0; + foreach (var scenario in DeskVectors.Rows("transport", "listShortPages")) + { + 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 name = (string)scenario["name"]!; + if (step["refusal"] is { } refusal) + { + factory.Door.ListScript = (_, _) => new DeskListOutcome.ForkTooLarge((string)step["fork_id"]!, (int)step["order"]!); + } + else + { + var list = step["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(step["refusal"] is null ? 200 : (int)step["httpStatus"]!, reply.Status); + Assert.True( + step["refusal"] is null + ? DeskVectors.SameShape(JsonNode.Parse(reply.Body), step["response"]) + : DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), step["refusal"], (string)step["fork_id"]!, (int)step["order"]!), + name); + steps++; + } + } + + Assert.Equal(6, steps); + } + + [Fact] + public async Task A_read_past_the_cap_follows_the_vectors() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + var rows = DeskVectors.Rows("transport", "readTooLarge").ToList(); + Assert.Single(rows); + foreach (var row in rows) + { + var forkId = (string)row["fork_id"]!; + factory.Door.ReadScript = (_, id) => new DeskReadOutcome.ForkTooLarge(id); + var reply = await factory.SendAsync("GET", DeskReplay.Target("read", (string)row["request"]!["mind"]!, forkId), null, ReadToken(factory)); + + Assert.Equal((int)row["httpStatus"]!, reply.Status); + Assert.True(DeskVectors.SameRefusal(JsonNode.Parse(reply.Body), row["refusal"], forkId, null), (string)row["name"]!); + } + } + + [Fact] + public async Task Door_faults_follow_the_vectors_per_route() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + var faults = DeskVectors.Rows("transport", "doorFaults").ToList(); + Assert.Equal(3, faults.Count); + foreach (var row in faults) + { + var code = (string)row["doorCode"]!; + string method = "GET", target; + string? bearer = null; + byte[]? body = null; + switch ((string)row["route"]!) + { + case "read": + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code); + target = DeskReplay.Target("read", "nibu", "nibu:question:glow-order"); + bearer = ReadToken(factory); + break; + case "list": + factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code); + target = "/api/desk/nibu/forks"; + bearer = ReadToken(factory); + break; + default: + method = "POST"; + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code); + target = DeskReplay.Target("answer", "nibu", null); + body = DeskReplay.AnswerBody("nibu:question:glow-order"); + bearer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"); + break; + } + + var reply = await factory.SendAsync(method, target, body, bearer); + + Assert.Equal((int)row["httpStatus"]!, reply.Status); + Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), row["response"]), (string)row["name"]!); + } + } + + [Fact] + public async Task A_short_page_with_a_cursor_below_its_last_entry_passes_through_unchanged() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed(new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 3, 0, [], "c2")); + + var reply = await factory.SendAsync("GET", "/api/desk/nibu/forks?limit=10", null, ReadToken(factory)); + + Assert.Equal(200, reply.Status); + Assert.Equal("c2", (string?)JsonNode.Parse(reply.Body)!["next_cursor"]); + } + + [Fact] + public async Task A_door_that_refuses_a_read_or_list_for_a_body_field_such_as_authority_is_request_invalid_not_unavailable() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + foreach (var code in new[] { "body_forbidden_field", "body_unknown_field", "body_invalid" }) + { + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(code); + factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(code); + foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") }) + { + var reply = await factory.SendAsync("GET", target, null, ReadToken(factory)); + Assert.Equal(400, reply.Status); + Assert.Equal("request_invalid", (string?)JsonNode.Parse(reply.Body)!["code"]); + } + } + } + + [Fact] + public async Task request_id_conflict_is_the_answers_alone_on_read_and_list_it_is_a_door_fault() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.NotFound(DeskRefusals.RequestIdConflict); + factory.Door.ListScript = (_, _) => new DeskListOutcome.NotListed(DeskRefusals.RequestIdConflict); + foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") }) + { + var reply = await factory.SendAsync("GET", target, null, ReadToken(factory)); + Assert.Equal(503, reply.Status); + Assert.Equal("unavailable", (string?)JsonNode.Parse(reply.Body)!["code"]); + } + } + + [Fact] + public async Task Door_failure_on_list_and_read_is_unavailable() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + factory.Door.Down = true; + + foreach (var target in new[] { "/api/desk/nibu/forks", DeskReplay.Target("read", "nibu", "nibu:question:glow-order") }) + { + var reply = await factory.SendAsync("GET", target, null, ReadToken(factory)); + Assert.Equal(503, reply.Status); + Assert.Equal(30, (int?)JsonNode.Parse(reply.Body)!["retryAfterSeconds"]); + } + } + + [Fact] + public async Task Every_door_call_carries_the_configured_deciders_and_nothing_in_the_request_can_change_them() + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var hostile = "deciders=" + DeskWebApplicationFactory.Member; + Action headers = http => + { + http.Request.Headers["X-Deciders"] = DeskWebApplicationFactory.Member; + http.Request.Headers["Deciders"] = DeskWebApplicationFactory.Member; + }; + var read = ReadToken(factory); + var answer = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"); + + await factory.SendAsync("GET", "/api/desk/nibu/forks", null, read, configure: headers); + var hostileList = await factory.SendAsync("GET", "/api/desk/nibu/forks?" + hostile, null, read, configure: headers); + Assert.Equal(400, hostileList.Status); + await factory.SendAsync("GET", DeskReplay.Target("read", "nibu", "nibu:question:glow-order"), null, read, configure: headers); + await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null) + "?" + hostile, DeskReplay.AnswerBody("nibu:question:glow-order"), answer, configure: headers); + // A body naming deciders never reaches the door. + var body = JsonNode.Parse(DeskReplay.AnswerBody("nibu:question:glow-order"))!.AsObject(); + body["deciders"] = new JsonArray(DeskWebApplicationFactory.Member); + var named = await factory.SendAsync("POST", DeskReplay.Target("answer", "nibu", null), DeskVectors.Bytes(body), answer); + + Assert.Equal("body_unknown_field", (string?)JsonNode.Parse(named.Body)!["code"]); + Assert.Equal(["list", "read", "answer"], factory.Door.Calls.Select(c => c.Kind).ToArray()); + Assert.All(factory.Door.Calls, c => Assert.Equal(DeskWebApplicationFactory.DecidersOf("nibu"), c.Context.Deciders)); + } + private const string Secret = "the decider's own words"; + + 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, 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() + { + var entry = DeskReplay.Entry(DeskReplay.Card(DeskReplay.ChatFork)); + DeskAnswerDocument[] answers = + [ + Doc("n1", "Counted", null, null, Secret), + Doc("n2", "Revision", "n1", null, Secret + " 2"), + Doc("n3", "Retraction", null, "n2", Secret + " 3"), + Doc("n4", "Refused", null, null, Secret + " 4"), + ]; + return entry with { Record = entry.Record with { CountedAnswerId = "n1", Answers = answers } }; + } + + private static bool AnyNote(JsonNode? node) => node switch + { + JsonObject o => o.Any(kv => (kv.Key == "note" && kv.Value is not null) || AnyNote(kv.Value)), + JsonArray a => a.Any(AnyNote), + _ => false, + }; + + private static async Task<(string Read, string List)> BodiesAsync(DeskWebApplicationFactory factory, string account) + { + var entry = NotedEntry(); + factory.Door.ReadScript = (_, _) => new DeskReadOutcome.Found(entry); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed( + new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [entry, entry], null)); + var token = factory.Token(account, "bifrost-desk", "desk.read"); + var read = await factory.SendAsync("GET", DeskReplay.Target("read", "gamecult-chat", DeskReplay.ChatFork), null, token); + var list = await factory.SendAsync("GET", DeskReplay.Target("list", "gamecult-chat", null), null, token); + Assert.Equal(200, read.Status); + Assert.Equal(200, list.Status); + return (System.Text.Encoding.UTF8.GetString(read.Body), System.Text.Encoding.UTF8.GetString(list.Body)); + } + + [Fact] + public async Task A_non_decider_reads_and_lists_no_notes_anywhere_and_the_body_keeps_the_contract_shape() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true); + Assert.DoesNotContain(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("gamecult-chat")); + + var (read, list) = await BodiesAsync(factory, DeskWebApplicationFactory.Member); + + Assert.DoesNotContain("decider's own words", read); + 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 + { + Record = NotedEntry().Record with { Answers = [.. NotedEntry().Record.Answers.Select(a => a with { Note = null })] }, + }, DeskJson.Options); + Assert.True(DeskVectors.SameShape(JsonNode.Parse(read), expected)); + var answers = JsonNode.Parse(read)!["record"]!["answers"]!.AsArray(); + Assert.Equal(new[] { "Counted", "Revision", "Retraction", "Refused" }, answers.Select(a => (string)a!["outcome"]!).ToArray()); + Assert.All(answers, a => Assert.True(a!.AsObject().ContainsKey("note"))); + 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() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + 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))); + } + + [Fact] + public async Task Decider_status_is_per_mind_the_same_account_is_stripped_where_it_is_not_on_the_list() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Member, true); + Assert.Contains(DeskWebApplicationFactory.Member, DeskWebApplicationFactory.DecidersOf("nibu")); + factory.Door.ListScript = (_, r) => new DeskListOutcome.Listed( + new ForkList("gamecult.fork.list.v0", r.Mind, "s1", 1, 0, [NotedEntry()], null)); + var token = factory.Token(DeskWebApplicationFactory.Member, "bifrost-desk", "desk.read"); + + var onNibu = await factory.SendAsync("GET", "/api/desk/nibu/forks", null, token); + var onChat = await factory.SendAsync("GET", "/api/desk/gamecult-chat/forks", null, token); + + Assert.True(AnyNote(JsonNode.Parse(onNibu.Body))); + Assert.False(AnyNote(JsonNode.Parse(onChat.Body))); + } +} 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 new file mode 100644 index 0000000..dca4862 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskScenarioTests.cs @@ -0,0 +1,188 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskScenarioTests +{ + private static readonly string Socket = DeskWebApplicationFactory.SocketOf("gamecult-chat"); + + private static readonly string[] ConfiguredDeciders = DeskWebApplicationFactory.DecidersOf("gamecult-chat"); + + // Replays every answer step of every scenario. The door is scripted from the vector's response; what Bifrost + // must produce by itself (edge refusals, unavailable, the attached card) is compared with the vector. + private sealed record Counts(int Answers, int Edge, int Unavailable, int Skipped); + + private static async Task ReplayAsync(Action? observe = null) + { + int answers = 0, edge = 0, unavailable = 0, skipped = 0; + foreach (var scenario in DeskVectors.Rows("scenarios")) + { + using var factory = new DeskWebApplicationFactory(); + var door = factory.Door; + 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())) + { + var kind = (string)step["kind"]!; + if (kind == "matrix_event") + { + skipped++; // no Matrix ingress exists, so there is no route a Matrix event could take + continue; + } + + if (kind == "card") + { + door.Forks[(Socket, DeskReplay.ChatFork)] = DeskReplay.Entry(step["card"]!.Deserialize(DeskJson.Options)); + continue; + } + + var server = step["server"]!.AsObject(); + var response = step["response"]!.AsObject(); + var code = (string?)response["code"]; + var answerer = (string?)server["answerer"]; + var active = (bool?)server["active"] ?? true; + + door.Calls.Clear(); + door.StoredIn.Clear(); + door.Down = false; + door.ThrowAfterStore = false; + door.AnswerScript = null; + if (answerer is not null) + { + await factory.EnsureAccountAsync(answerer, active); + } + + var isDown = (bool?)server["door_down"] == true; + var stored = (bool?)server["stored"] == true; + if (isDown) + { + door.Down = !stored; + door.ThrowAfterStore = stored; + } + + if (code is null) + { + var wire = response.Deserialize(DeskJson.Options)!; + // Huginn answers without the card; Bifrost attaches the current one for stale_fork. + door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(wire with { Card = null }); + } + else if (code is "request_id_conflict" or "fork_unknown") + { + door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.NotStored(code); + } + + var body = DeskVectors.Bytes(step["body"]!); + var token = answerer is null + ? null + : factory.Token(answerer, "bifrost-desk", "desk.answer"); + var reply = await factory.SendAsync("POST", DeskReplay.Target("answer", "gamecult-chat", null), body, token); + + var label = $"{scenarioName}"; + if (code == "unavailable") + { + unavailable++; + Assert.True(503 == reply.Status, $"{label}: {reply.Status}"); + Assert.Equal("30", reply.Headers.RetryAfter.ToString()); + Assert.Equal(stored ? [Socket] : [], door.StoredIn); + } + else if (code is not null && DeskReplay.IsEdgeRefusal(code)) + { + edge++; + Assert.True(DeskRefusals.StatusOf(code) == reply.Status, $"{label}: {reply.Status}"); + Assert.True(door.Calls.Count == 0, $"{label}: the door was called for an edge refusal"); + } + else + { + answers++; + var call = Assert.Single(door.Calls.Where(c => c.Kind == "answer")); + Assert.Equal(body, call.Body); + Assert.Equal(answerer, call.Context.Subject); + Assert.Equal((string)server["authority"]!, call.Context.Authority); + Assert.Equal("gamecult-chat", call.Context.Mind); + Assert.Equal(ConfiguredDeciders, call.Context.Deciders); + Assert.Equal(Socket, call.Socket); + } + + Assert.True( + DeskVectors.SameShape(JsonNode.Parse(reply.Body), response), + $"{label}: response differs\n{reply.Text}\n{response.ToJsonString()}"); + observe?.Invoke(scenarioName, reply, factory, step); + } + } + + return new Counts(answers, edge, unavailable, skipped); + } + + [Fact] + public async Task Every_scenario_answer_step_replays_through_http() + { + var counts = await ReplayAsync(); + + Assert.Equal(3, counts.Skipped); + Assert.Equal(2, counts.Unavailable); + Assert.Equal(15, counts.Edge); + Assert.Equal(100, counts.Answers); + } + + [Fact] + public async Task Edge_refusals_never_call_the_door() + { + var edges = 0; + await ReplayAsync((_, reply, factory, step) => + { + var code = (string?)step["response"]!["code"]; + if (code is not null && DeskReplay.IsEdgeRefusal(code)) + { + edges++; + Assert.Empty(factory.Door.Calls); + } + }); + Assert.Equal(15, edges); + } + + [Fact] + public async Task Answer_results_pass_through_unchanged() + { + var checkedResults = 0; + foreach (var row in DeskVectors.Rows("results")) + { + using var factory = new DeskWebApplicationFactory(); + var message = row["message"]!.AsObject(); + 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)); + factory.Door.AnswerScript = (_, _, _) => new DeskAnswerOutcome.Answered(result with { Card = null }); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + var reply = await factory.SendAsync( + "POST", DeskReplay.Target("answer", "gamecult-chat", null), DeskReplay.AnswerBody(answer.ForkId), + factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer")); + + Assert.True((int)row["httpStatus"]! == reply.Status, $"{row["name"]}: {reply.Status}"); + Assert.True(DeskVectors.SameShape(JsonNode.Parse(reply.Body), message), $"{row["name"]}"); + checkedResults++; + } + + Assert.Equal(6, checkedResults); + } + + [Fact] + public async Task Unavailable_before_and_after_store() + { + // The scenarios hold both: door_down before the store, and door_down after it (the retry then replays). + var seen = new List<(bool Stored, int Status)>(); + await ReplayAsync((_, reply, factory, step) => + { + if ((string?)step["response"]!["code"] == "unavailable") + { + seen.Add(((bool?)step["server"]!["stored"] == true, reply.Status)); + } + }); + Assert.Equal([(false, 503), (true, 503)], seen.OrderBy(s => s.Stored).ToArray()); + } +} diff --git a/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs new file mode 100644 index 0000000..b0da228 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskSurfaceTests.cs @@ -0,0 +1,150 @@ +using System.Net.Sockets; +using System.Text; +using System.Text.Json.Nodes; +using Bifrost.Web.Features.Desk; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bifrost.Web.Tests; + +public sealed class DeskSurfaceTests +{ + private static readonly string AnswerTarget = DeskReplay.Target("answer", "gamecult-chat", null); + + [Fact] + public async Task Not_mapped_without_config() + { + using var factory = new DeskWebApplicationFactory(configured: false); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + var reply = await factory.SendAsync( + "POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), + factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer")); + + Assert.Equal(404, reply.Status); + Assert.Empty(factory.Door.Calls); + var routes = factory.Services.GetRequiredService().Endpoints.OfType(); + Assert.DoesNotContain(routes, e => e.RoutePattern.RawText!.StartsWith("/api/desk", StringComparison.Ordinal)); + } + + [Fact] + public void A_configured_desk_with_no_door_supplied_gets_the_production_unix_socket_door() + { + using var factory = new DeskWebApplicationFactory(registerPorts: false); + + factory.CreateClient(); + + // 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] + public async Task Cookie_session_does_not_authenticate() + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + + // The test host signs every request in as an Active-looking site member through the default scheme. The desk ignores it. + var reply = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork)); + var withCookie = await factory.SendAsync( + "POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), + configure: http => http.Request.Headers.Cookie = "bifrost_session=anything"); + + Assert.Equal(401, reply.Status); + Assert.Equal(401, withCookie.Status); + Assert.Empty(factory.Door.Calls); + } + + [Fact] + public async Task Cors_allows_exactly_the_configured_origin_without_credentials() + { + using var factory = new DeskWebApplicationFactory(); + DeskReplay.HoldVectorForks(factory.Door); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"); + + var allowed = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin); + var refused = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, "https://evil.example"); + var lookalike = await factory.SendAsync("POST", AnswerTarget, DeskReplay.AnswerBody(DeskReplay.ChatFork), token, DeskWebApplicationFactory.Origin + ".evil.example"); + var preflight = await factory.SendAsync("OPTIONS", AnswerTarget, null, null, DeskWebApplicationFactory.Origin, http => + { + http.Request.Headers["Access-Control-Request-Method"] = "POST"; + http.Request.Headers["Access-Control-Request-Headers"] = "authorization,content-type"; + }); + var elsewhere = await factory.SendAsync("GET", "/healthz", null, null, DeskWebApplicationFactory.Origin); + + Assert.Equal(DeskWebApplicationFactory.Origin, allowed.Headers.AccessControlAllowOrigin.ToString()); + Assert.False(allowed.Headers.ContainsKey("Access-Control-Allow-Credentials")); + Assert.Contains("Origin", allowed.Headers.Vary.ToString()); + Assert.False(refused.Headers.ContainsKey("Access-Control-Allow-Origin")); + Assert.False(lookalike.Headers.ContainsKey("Access-Control-Allow-Origin")); + Assert.Equal(DeskWebApplicationFactory.Origin, preflight.Headers.AccessControlAllowOrigin.ToString()); + Assert.False(preflight.Headers.ContainsKey("Access-Control-Allow-Credentials")); + Assert.Contains("POST", preflight.Headers.AccessControlAllowMethods.ToString()); + Assert.False(elsewhere.Headers.ContainsKey("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task An_unrecognised_target_is_fork_unknown_and_opens_nothing() + { + using var factory = new DeskWebApplicationFactory(); + await factory.EnsureAccountAsync(DeskWebApplicationFactory.Alice, true); + var token = factory.Token(DeskWebApplicationFactory.Alice, "bifrost-desk", "desk.answer"); + + foreach (var target in new[] + { + "/api/desk", "/api/desk/", "/api/desk/gamecult-chat", "/api/desk/gamecult-chat/answers/", "/api/desk/a/b/answers", + "/api/desk/a/../gamecult-chat/answers", "/api/desk/./gamecult-chat/answers", "/api/desk//answers/x", + "/API/DESK/gamecult-chat/answers", "/api/deskx/gamecult-chat/answers", "http://localhost/api/desk/gamecult-chat/answers", + }) + { + var reply = await factory.SendAsync("POST", target, DeskReplay.AnswerBody(DeskReplay.ChatFork), token); + Assert.True(reply.Status == 404, $"{target}: {reply.Status}"); + } + + Assert.Empty(factory.Door.Calls); + } + + // Kestrel normalises Request.Path but leaves RawTarget as received. The desk parses RawTarget, so a dot + // segment can never be folded into a configured Mind's route. + [Fact] + public async Task Dot_segments_never_reach_the_route() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + await using var app = builder.Build(); + app.Run(async http => + { + var raw = http.Features.Get()!.RawTarget; + var text = $"{http.Request.Path}|{raw}"; + http.Response.ContentLength = text.Length; + await http.Response.WriteAsync(text); + }); + await app.StartAsync(); + var address = app.Services.GetRequiredService().Features.Get()!.Addresses.First(); + var port = new Uri(address).Port; + + using var client = new TcpClient(); + await client.ConnectAsync("127.0.0.1", port); + var stream = client.GetStream(); + await stream.WriteAsync(Encoding.ASCII.GetBytes("GET /api/desk/ghost/../gamecult-chat/answers HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")); + using var reader = new StreamReader(stream, Encoding.ASCII); + var text = await reader.ReadToEndAsync(); + var echoed = text[(text.LastIndexOf("\r\n\r\n", StringComparison.Ordinal) + 4)..].Trim(); + + var parts = echoed.Split('|'); + Assert.Equal("/api/desk/gamecult-chat/answers", parts[0]); + Assert.Equal("/api/desk/ghost/../gamecult-chat/answers", parts[1]); + Assert.Null(DeskRoute.Parse("POST", parts[1])); + Assert.NotNull(DeskRoute.Parse("POST", parts[0])); + } +} 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 new file mode 100644 index 0000000..64e7df2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/DeskVectorPinTests.cs @@ -0,0 +1,19 @@ +using System.Security.Cryptography; +using Bifrost.Web.Tests.Support; + +namespace Bifrost.Web.Tests; + +public sealed class DeskVectorPinTests +{ + [Fact] + 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( + DeskVectors.FileSha256 == sha, + $"fork-card-vectors.json is not CultLib {DeskVectors.SourceCommit}:contracts/cultnet/governance/fork-card-vectors.json (sha256 {sha}). " + + "Re-vendor from that commit or move the pin on purpose, with the whole contract."); + } +} 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/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..4ae3eae 100644 --- a/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -35,12 +35,13 @@ function run(name, search, hash) { }, }; const form = { - elements: { attempt: { value: "" }, code: { value: "" } }, + elements: { attempt: { value: "" }, code: { value: "" }, pendingLinkId: { value: "" } }, submit() { events.push("submit"); submit = { attempt: form.elements.attempt.value, code: form.elements.code.value, + pendingLinkId: form.elements.pendingLinkId.value, href: location.href, hash: location.hash, }; @@ -59,6 +60,10 @@ console.log( run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete), run("no-code", "?attempt=NONCE", "#heimdall_status=success"), run("no-attempt", "", "#heimdall_status=success" + complete), + run("link-return", "?attempt=NONCE", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), + run("link-return-with-decoy-query-id", "?attempt=NONCE&heimdall_pending_link_id=QPEND", "#heimdall_status=success&heimdall_pending_link_id=PEND1"), + run("link-error-with-pending-id", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied&heimdall_pending_link_id=PEND1"), + run("link-pending-id-in-query-only", "?attempt=NONCE&heimdall_pending_link_id=QPEND", ""), run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""), ]), ); diff --git a/tests/Bifrost.Web.Tests/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 new file mode 100644 index 0000000..e3e4ae9 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json @@ -0,0 +1,23014 @@ +{ + "provenance": "Rule source: /opt/commons/handoff/fork-desk-answer-binding-20261110.md (Heimdall), amended by the Persona briefs of 2026-11-10 (Eureka's fit check). Bytes spec-authored with msgpack-python (scripts/gen-governance-vectors.py); every runtime re-encodes `message` and must match messagePackHex. options_hash follows fork-card.md 'Options hash'; the vectors named heimdall_* are that file's, with its digests.", + "optionsHash": [ + { + "name": "heimdall_A_five_members", + "heimdall": "A", + "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" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + { + "name": "heimdall_B_recommendation_changed", + "heimdall": "B", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "yes", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"yes\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A22796573222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc" + }, + { + "name": "heimdall_C_option_order_changed", + "heimdall": "C", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "no", + "text": "Keep it local." + }, + { + "id": "yes", + "text": "Share it." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"no\",\"text\":\"Keep it local.\"},{\"id\":\"yes\",\"text\":\"Share it.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D2C7B226964223A22796573222C2274657874223A2253686172652069742E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "ac67b41539f869f6203ac0908819df046e4328b27795743a919bd4ab390596b5" + }, + { + "name": "heimdall_D_nfc_escapes_astral", + "heimdall": "D", + "title": "Café “desk”", + "context": "Line one\nLine two — 😀", + "options": [ + { + "id": "a", + "text": "x" + }, + { + "id": "b", + "text": "y" + } + ], + "recommendation": "a", + "depends": [], + "canonical": "{\"context\":\"Line one\\nLine two — 😀\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Café “desk”\"}", + "canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D", + "sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272" + }, + { + "name": "heimdall_E_decomposed_equals_D", + "heimdall": "E", + "title": "Café “desk”", + "context": "Line one\nLine two — 😀", + "options": [ + { + "id": "a", + "text": "x" + }, + { + "id": "b", + "text": "y" + } + ], + "recommendation": "a", + "depends": [], + "canonical": "{\"context\":\"Line one\\nLine two — 😀\",\"depends\":[],\"options\":[{\"id\":\"a\",\"text\":\"x\"},{\"id\":\"b\",\"text\":\"y\"}],\"recommendation\":\"a\",\"title\":\"Café “desk”\"}", + "canonicalHex": "7B22636F6E74657874223A224C696E65206F6E655C6E4C696E652074776F20E2809420F09F9880222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A2261222C2274657874223A2278227D2C7B226964223A2262222C2274657874223A2279227D5D2C227265636F6D6D656E646174696F6E223A2261222C227469746C65223A22436166C3A920E2809C6465736BE2809D227D", + "sha256": "71d5c413478ad06aaa1cbdd9e91c53f0cb4cef1dbd4c51bf3d473bccc6a5a272" + }, + { + "name": "heimdall_F_option_id_changed", + "heimdall": "F", + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "share", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"share\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A227368617265222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "fb53aa3a9f7e74029f755d774064eedd240ab90d8fc64723f97c31b3d2846d40" + }, + { + "name": "heimdall_G_depends_emptied", + "heimdall": "G", + "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": [], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B5D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86" + }, + { + "name": "option_text_changed", + "heimdall": null, + "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" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it!\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A22536861726520697421227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "9ce0d2770497fdd2d5338e66f9f1ae1741d87301db4c793407c25762929392b6" + }, + { + "name": "title_changed", + "heimdall": null, + "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" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent?\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E743F227D", + "sha256": "30fe0c5bc88bd21d64592a495316c30e80fcee6cacf5c63fd9a818ca5cf87d0f" + }, + { + "name": "context_changed", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should slot marks be shared?", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should slot marks be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C6420736C6F74206D61726B73206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "716de65096e34fd89cc92848b667d90f5c27eeb66929da3d340cabb80155e173" + }, + { + "name": "depends_order_changed", + "heimdall": null, + "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", + "x:question:y" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\",\"x:question:y\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365222C22783A7175657374696F6E3A79225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "fc3f651c6f498385256fc1232d5db31ece9dcc542e391f97245d27bc9e385b3d" + }, + { + "name": "depends_order_changed_reversed", + "heimdall": null, + "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": [ + "x:question:y", + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"x:question:y\",\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B22783A7175657374696F6E3A79222C2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "b22642e30f8d68839e3a0df5bc23b83ff50a839fcecf0366a65fa6e3cf873796" + }, + { + "name": "hidden_members_do_not_move_it", + "heimdall": null, + "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" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + { + "name": "control_characters_and_markup_are_escaped_minimally", + "heimdall": null, + "title": "q \" b \\ t \t n \n bell \u0007 del ", + "context": " 
 &", + "options": [ + { + "id": "yes", + "text": "Share it." + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\" 
 &\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"yes\",\"text\":\"Share it.\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"q \\\" b \\\\ t \\t n \\n bell \\u0007 del \"}", + "canonicalHex": "7B22636F6E74657874223A223C2F7363726970743E20E280A82026222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22796573222C2274657874223A2253686172652069742E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A2271205C222062205C5C2074205C74206E205C6E2062656C6C205C75303030372064656C207F227D", + "sha256": "f2d5a09513ce728129f6ca13f7123aa75b2bb77541b1de8946f39239634b40fe" + }, + { + "name": "astral_option_id_and_text", + "heimdall": null, + "title": "Slot mark consent", + "context": "Should a slot mark be shared?", + "options": [ + { + "id": "😀", + "text": "𝄞" + }, + { + "id": "no", + "text": "Keep it local." + } + ], + "recommendation": "no", + "depends": [ + "gamecult-chat:question:file-zone-audience" + ], + "canonical": "{\"context\":\"Should a slot mark be shared?\",\"depends\":[\"gamecult-chat:question:file-zone-audience\"],\"options\":[{\"id\":\"😀\",\"text\":\"𝄞\"},{\"id\":\"no\",\"text\":\"Keep it local.\"}],\"recommendation\":\"no\",\"title\":\"Slot mark consent\"}", + "canonicalHex": "7B22636F6E74657874223A2253686F756C64206120736C6F74206D61726B206265207368617265643F222C22646570656E6473223A5B2267616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365225D2C226F7074696F6E73223A5B7B226964223A22F09F9880222C2274657874223A22F09D849E227D2C7B226964223A226E6F222C2274657874223A224B656570206974206C6F63616C2E227D5D2C227265636F6D6D656E646174696F6E223A226E6F222C227469746C65223A22536C6F74206D61726B20636F6E73656E74227D", + "sha256": "836fbfce16127132bae100aa773d328a85ad08d241ae9bf47ea7f84766762cac" + } + ], + "bodyDigests": [ + { + "name": "plain_answer_choice_A", + "status": "main_7ea11b4_must_not_move", + "why": "a plain answer: choice and note as sent, retracts null; no illegal or retracted key", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": "A", + "note": null, + "retracts": null, + "illegal_retraction": null, + "retracted": null + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":\"A\",\"note\":null,\"retracts\":null}", + "sha256": "8a4d5b2ed0b3452bfed2f3a276b2828974e6824a3edcc643cfe9cfcdda34173a" + }, + { + "name": "legal_retraction", + "status": "main_7ea11b4_must_not_move", + "why": "a legal retraction as Huginn main pins it: retracts is `Q1.1`, a string no door produces (the key grammar is .n); kept for the frozen-form test", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": null, + "note": null, + "retracts": "eureka-state:desk_answer:Q1.1", + "illegal_retraction": null, + "retracted": null + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":null,\"note\":null,\"retracts\":\"eureka-state:desk_answer:Q1.1\"}", + "sha256": "9f8075382556898c0bea364ed7c6deb2a86d8c78288a1f34e06da6e9bf5f58a3" + }, + { + "name": "illegal_i_and_iii_alike_on_main", + "status": "main_7ea11b4_must_not_move", + "why": "main writes `illegal_retraction: true` and no `retracted`, so (i) and (iii) collide: the defect the fix removes", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": "A", + "note": null, + "retracts": "eureka-state:desk_answer:Q1.n1", + "illegal_retraction": true, + "retracted": null + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":\"A\",\"note\":null,\"retracts\":\"eureka-state:desk_answer:Q1.n1\",\"illegal_retraction\":true}", + "sha256": "69074cc60829ef5edb12358746f7549ad08da97653d59359c945cea102f53565" + }, + { + "name": "legal_retraction_door_form", + "status": "main_7ea11b4_must_not_move", + "why": "a legal retraction as the door produces it: the wire's n1 converted to the full stored key Q1.n1 before the digest; the golden that stands for the real form (digest unaffected by .n vs .1)", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": null, + "note": null, + "retracts": "eureka-state:desk_answer:Q1.n1", + "illegal_retraction": null, + "retracted": null + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":null,\"note\":null,\"retracts\":\"eureka-state:desk_answer:Q1.n1\"}", + "sha256": "84576840247830b9b75754b460dc4ea52883ca1e410f9f011521e0f69b99eee1" + }, + { + "name": "illegal_ii_on_main", + "status": "main_7ea11b4_must_not_move", + "why": "(ii) no answer, no target, on main", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": null, + "note": null, + "retracts": null, + "illegal_retraction": true, + "retracted": null + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":null,\"note\":null,\"retracts\":null,\"illegal_retraction\":true}", + "sha256": "1a36c4aba0d1c30d0736f20243ebfdb84fe84a5eabd4f69709a096a32c3cc6e3" + }, + { + "name": "illegal_i_retraction_with_an_answer_retracted_true", + "status": "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", + "why": "(i) answer present, retracted true, target Q1.n1", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": "A", + "note": null, + "retracts": "eureka-state:desk_answer:Q1.n1", + "illegal_retraction": true, + "retracted": true + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":\"A\",\"note\":null,\"retracts\":\"eureka-state:desk_answer:Q1.n1\",\"illegal_retraction\":true,\"retracted\":true}", + "sha256": "935cab1a0ae34606b809d8ebca7cffa2889d20b3585d2622d32153a4d6898af1" + }, + { + "name": "illegal_iii_plain_answer_with_a_target_retracted_false", + "status": "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", + "why": "(iii) the same bytes as (i) with retracted false: another body, so request_id_conflict", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": "A", + "note": null, + "retracts": "eureka-state:desk_answer:Q1.n1", + "illegal_retraction": true, + "retracted": false + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":\"A\",\"note\":null,\"retracts\":\"eureka-state:desk_answer:Q1.n1\",\"illegal_retraction\":true,\"retracted\":false}", + "sha256": "82b24cccb4ed2535a549f95f844fcd935074c3712b5b832f0b2cbd89c71fd9a6" + }, + { + "name": "illegal_i_retraction_with_an_answer_and_no_target_retracted_true", + "status": "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", + "why": "(i) with the target null (the order doc: retracts set or null); Eureka re-derived it with sha256sum (order doc addendum)", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": "A", + "note": null, + "retracts": null, + "illegal_retraction": true, + "retracted": true + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":\"A\",\"note\":null,\"retracts\":null,\"illegal_retraction\":true,\"retracted\":true}", + "sha256": "79eee623fd6712db6537a4867cedf54ca49ee4885416c2c8e4acec59f811b894" + }, + { + "name": "illegal_ii_no_answer_no_target_retracted_true", + "status": "reproduced_on_huginn_main_f0ca504_reported_by_eureka_not_rerun_here", + "why": "(ii) no answer, no target, retracted true", + "input": { + "fork": "eureka-state:question:Q1", + "options_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "answered_at": "2026-11-10T12:00:00Z", + "choice": null, + "note": null, + "retracts": null, + "illegal_retraction": true, + "retracted": true + }, + "canonical": "{\"fork\":\"eureka-state:question:Q1\",\"options_hash\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"answered_at\":\"2026-11-10T12:00:00Z\",\"choice\":null,\"note\":null,\"retracts\":null,\"illegal_retraction\":true,\"retracted\":true}", + "sha256": "34ef86ed63b9318459965d4b85e794d2636ffc15736da866036b087c9c4a30eb" + } + ], + "card": { + "valid": [ + { + "name": "operator_fork", + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + }, + { + "name": "leans_flag_closed_two_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": [], + "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": [ + "n1" + ], + "order": 0, + "options_hash": "f856edce121db4121b5f1af400b1c471ebbfa6579a042aca9a34ae31c15f5c86" + }, + "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", + "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": [ + "a", + "a", + "a", + "a", + "a", + "a", + "a", + "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + ], + "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": 3, + "options_hash": "52163c7ae8a2c15d4380e7854505bedb5b3c84976ed301d86afc99a7bd0e03c8" + }, + "messagePackHex": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647398A161A161A161A161A161A161A161DA03E8C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94035323136336337616538613263313564343338306537383534353035626564623562336338343937366564333031643836616663393961376264306530336338" + }, + { + "name": "closed_without_a_ruling_the_question_was_withdrawn", + "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" + ], + "decider_count": 1, + "status": "closed", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "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", + "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": [ + "00000001-0000-4000-8000-000000000001", + "00000002-0000-4000-8000-000000000002", + "00000003-0000-4000-8000-000000000003", + "00000004-0000-4000-8000-000000000004", + "00000005-0000-4000-8000-000000000005", + "00000006-0000-4000-8000-000000000006", + "00000007-0000-4000-8000-000000000007", + "00000008-0000-4000-8000-000000000008", + "00000009-0000-4000-8000-000000000009", + "0000000a-0000-4000-8000-00000000000a", + "0000000b-0000-4000-8000-00000000000b", + "0000000c-0000-4000-8000-00000000000c", + "0000000d-0000-4000-8000-00000000000d", + "0000000e-0000-4000-8000-00000000000e", + "0000000f-0000-4000-8000-00000000000f", + "00000010-0000-4000-8000-000000000010", + "00000011-0000-4000-8000-000000000011", + "00000012-0000-4000-8000-000000000012", + "00000013-0000-4000-8000-000000000013", + "00000014-0000-4000-8000-000000000014", + "00000015-0000-4000-8000-000000000015", + "00000016-0000-4000-8000-000000000016", + "00000017-0000-4000-8000-000000000017", + "00000018-0000-4000-8000-000000000018", + "00000019-0000-4000-8000-000000000019", + "0000001a-0000-4000-8000-00000000001a", + "0000001b-0000-4000-8000-00000000001b", + "0000001c-0000-4000-8000-00000000001c", + "0000001d-0000-4000-8000-00000000001d", + "0000001e-0000-4000-8000-00000000001e", + "0000001f-0000-4000-8000-00000000001f", + "00000020-0000-4000-8000-000000000020", + "00000021-0000-4000-8000-000000000021", + "00000022-0000-4000-8000-000000000022", + "00000023-0000-4000-8000-000000000023", + "00000024-0000-4000-8000-000000000024", + "00000025-0000-4000-8000-000000000025", + "00000026-0000-4000-8000-000000000026", + "00000027-0000-4000-8000-000000000027", + "00000028-0000-4000-8000-000000000028", + "00000029-0000-4000-8000-000000000029", + "0000002a-0000-4000-8000-00000000002a", + "0000002b-0000-4000-8000-00000000002b", + "0000002c-0000-4000-8000-00000000002c", + "0000002d-0000-4000-8000-00000000002d", + "0000002e-0000-4000-8000-00000000002e", + "0000002f-0000-4000-8000-00000000002f", + "00000030-0000-4000-8000-000000000030", + "00000031-0000-4000-8000-000000000031", + "00000032-0000-4000-8000-000000000032", + "00000033-0000-4000-8000-000000000033", + "00000034-0000-4000-8000-000000000034", + "00000035-0000-4000-8000-000000000035", + "00000036-0000-4000-8000-000000000036", + "00000037-0000-4000-8000-000000000037", + "00000038-0000-4000-8000-000000000038", + "00000039-0000-4000-8000-000000000039", + "0000003a-0000-4000-8000-00000000003a", + "0000003b-0000-4000-8000-00000000003b", + "0000003c-0000-4000-8000-00000000003c", + "0000003d-0000-4000-8000-00000000003d", + "0000003e-0000-4000-8000-00000000003e", + "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": "DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA86465636964657273DC0040D92430303030303030312D303030302D343030302D383030302D303030303030303030303031D92430303030303030322D303030302D343030302D383030302D303030303030303030303032D92430303030303030332D303030302D343030302D383030302D303030303030303030303033D92430303030303030342D303030302D343030302D383030302D303030303030303030303034D92430303030303030352D303030302D343030302D383030302D303030303030303030303035D92430303030303030362D303030302D343030302D383030302D303030303030303030303036D92430303030303030372D303030302D343030302D383030302D303030303030303030303037D92430303030303030382D303030302D343030302D383030302D303030303030303030303038D92430303030303030392D303030302D343030302D383030302D303030303030303030303039D92430303030303030612D303030302D343030302D383030302D303030303030303030303061D92430303030303030622D303030302D343030302D383030302D303030303030303030303062D92430303030303030632D303030302D343030302D383030302D303030303030303030303063D92430303030303030642D303030302D343030302D383030302D303030303030303030303064D92430303030303030652D303030302D343030302D383030302D303030303030303030303065D92430303030303030662D303030302D343030302D383030302D303030303030303030303066D92430303030303031302D303030302D343030302D383030302D303030303030303030303130D92430303030303031312D303030302D343030302D383030302D303030303030303030303131D92430303030303031322D303030302D343030302D383030302D303030303030303030303132D92430303030303031332D303030302D343030302D383030302D303030303030303030303133D92430303030303031342D303030302D343030302D383030302D303030303030303030303134D92430303030303031352D303030302D343030302D383030302D303030303030303030303135D92430303030303031362D303030302D343030302D383030302D303030303030303030303136D92430303030303031372D303030302D343030302D383030302D303030303030303030303137D92430303030303031382D303030302D343030302D383030302D303030303030303030303138D92430303030303031392D303030302D343030302D383030302D303030303030303030303139D92430303030303031612D303030302D343030302D383030302D303030303030303030303161D92430303030303031622D303030302D343030302D383030302D303030303030303030303162D92430303030303031632D303030302D343030302D383030302D303030303030303030303163D92430303030303031642D303030302D343030302D383030302D303030303030303030303164D92430303030303031652D303030302D343030302D383030302D303030303030303030303165D92430303030303031662D303030302D343030302D383030302D303030303030303030303166D92430303030303032302D303030302D343030302D383030302D303030303030303030303230D92430303030303032312D303030302D343030302D383030302D303030303030303030303231D92430303030303032322D303030302D343030302D383030302D303030303030303030303232D92430303030303032332D303030302D343030302D383030302D303030303030303030303233D92430303030303032342D303030302D343030302D383030302D303030303030303030303234D92430303030303032352D303030302D343030302D383030302D303030303030303030303235D92430303030303032362D303030302D343030302D383030302D303030303030303030303236D92430303030303032372D303030302D343030302D383030302D303030303030303030303237D92430303030303032382D303030302D343030302D383030302D303030303030303030303238D92430303030303032392D303030302D343030302D383030302D303030303030303030303239D92430303030303032612D303030302D343030302D383030302D303030303030303030303261D92430303030303032622D303030302D343030302D383030302D303030303030303030303262D92430303030303032632D303030302D343030302D383030302D303030303030303030303263D92430303030303032642D303030302D343030302D383030302D303030303030303030303264D92430303030303032652D303030302D343030302D383030302D303030303030303030303265D92430303030303032662D303030302D343030302D383030302D303030303030303030303266D92430303030303033302D303030302D343030302D383030302D303030303030303030303330D92430303030303033312D303030302D343030302D383030302D303030303030303030303331D92430303030303033322D303030302D343030302D383030302D303030303030303030303332D92430303030303033332D303030302D343030302D383030302D303030303030303030303333D92430303030303033342D303030302D343030302D383030302D303030303030303030303334D92430303030303033352D303030302D343030302D383030302D303030303030303030303335D92430303030303033362D303030302D343030302D383030302D303030303030303030303336D92430303030303033372D303030302D343030302D383030302D303030303030303030303337D92430303030303033382D303030302D343030302D383030302D303030303030303030303338D92430303030303033392D303030302D343030302D383030302D303030303030303030303339D92430303030303033612D303030302D343030302D383030302D303030303030303030303361D92430303030303033622D303030302D343030302D383030302D303030303030303030303362D92430303030303033632D303030302D343030302D383030302D303030303030303030303363D92430303030303033642D303030302D343030302D383030302D303030303030303030303364D92430303030303033652D303030302D343030302D383030302D303030303030303030303365D92430303030303033662D303030302D343030302D383030302D303030303030303030303366D92430303030303034302D303030302D343030302D383030302D303030303030303030303430AD646563696465725F636F756E7440A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438" + } + ], + "invalid": [ + { + "name": "depends_nine_lines", + "why": "depends holds at most 8 lines (the stored question's limit)", + "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": [ + "a", + "a", + "a", + "a", + "a", + "a", + "a", + "a", + "a" + ], + "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": 3, + "options_hash": "d0f895ec9cf7e17f4e053e3065b41b3ad34a808b5a5a2912dce4fd6a0a0a890f" + } + }, + { + "name": "depends_line_1001_code_points", + "why": "a depends line is at most 1000 characters", + "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": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ], + "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": 3, + "options_hash": "47d1a35dffaa09893998b3110ebe9c0fa4dab410b3f08a4c6ab78ed609072c99" + } + }, + { + "name": "depends_line_1001_bytes", + "why": "rule: one byte over the limit (500 two-byte characters and one more; 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": [ + "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééa" + ], + "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": 3, + "options_hash": "63d01e8c6dfcfddda061522e3c4bab7d54d1f1b3edf384a74725f034e8dc1f88" + } + }, + { + "name": "depends_line_over_1000_bytes", + "why": "rule: a depends line is at most 1000 UTF-8 bytes (the stored Line limit; 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": [ + "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + ], + "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": 3, + "options_hash": "e9459375267b29ebaacd409b005b6b2e4901fe8ff67b80163fba3573a804974e" + } + }, + { + "name": "one_option", + "why": "two or more options", + "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." + } + ], + "recommendation": "yes", + "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": 3, + "options_hash": "950f1ed77895e730a7e456e5d17801385b30b8b67d7185d977d0d0a6c6c6b5c2" + } + }, + { + "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", + "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": 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": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "null_recommendation", + "why": "every card recommends an option", + "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": null, + "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": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "unknown_key", + "why": "closed document", + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "note": "x" + } + }, + { + "name": "option_with_a_label", + "why": "options are {id,text}; the old format's display label is not a member (fork-card.md, Old format)", + "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.", + "label": "Yes" + }, + { + "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": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "status_unknown", + "why": "status is open or closed", + "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" + ], + "decider_count": 1, + "status": "provisional", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "options_hash_not_hex", + "why": "options_hash is 64 lowercase hex", + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "XYZ" + } + }, + { + "name": "duplicate_option_ids", + "why": "rule: option ids are unique (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": "yes", + "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": 3, + "options_hash": "d5aaa1c4c0e464c2015044f907fea8f6b7100ff7181677d14ce0982e059aac40" + } + }, + { + "name": "recommendation_not_an_option", + "why": "rule: recommendation is an option id (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": "maybe", + "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": 3, + "options_hash": "9077264ad75c31722641565acd76aab2dc248560aa8e90210973553414aba3f5" + } + }, + { + "name": "leans_not_an_option", + "why": "rule: leans is nil or an option id (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": "maybe", + "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": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "duplicate_deciders", + "why": "rule: deciders are unique (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", + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "ruled_from_on_an_open_card", + "why": "rule: a ruling that answers a fork closes it, so an open card cites no answer in ruled_from (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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "open_card_names_a_ruling", + "why": "rule: only a closed card names a ruling (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" + ], + "decider_count": 1, + "status": "open", + "ruling": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "ruled_from_without_a_ruling", + "why": "rule: ruled_from lists answers a ruling was admitted from, so it needs the ruling (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" + ], + "decider_count": 1, + "status": "closed", + "ruling": null, + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "name": "options_hash_of_another_card", + "why": "rule: options_hash equals the hash of the shown members (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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "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)", + "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": [ + "00000001-0000-4000-8000-000000000001", + "00000002-0000-4000-8000-000000000002", + "00000003-0000-4000-8000-000000000003", + "00000004-0000-4000-8000-000000000004", + "00000005-0000-4000-8000-000000000005", + "00000006-0000-4000-8000-000000000006", + "00000007-0000-4000-8000-000000000007", + "00000008-0000-4000-8000-000000000008", + "00000009-0000-4000-8000-000000000009", + "0000000a-0000-4000-8000-00000000000a", + "0000000b-0000-4000-8000-00000000000b", + "0000000c-0000-4000-8000-00000000000c", + "0000000d-0000-4000-8000-00000000000d", + "0000000e-0000-4000-8000-00000000000e", + "0000000f-0000-4000-8000-00000000000f", + "00000010-0000-4000-8000-000000000010", + "00000011-0000-4000-8000-000000000011", + "00000012-0000-4000-8000-000000000012", + "00000013-0000-4000-8000-000000000013", + "00000014-0000-4000-8000-000000000014", + "00000015-0000-4000-8000-000000000015", + "00000016-0000-4000-8000-000000000016", + "00000017-0000-4000-8000-000000000017", + "00000018-0000-4000-8000-000000000018", + "00000019-0000-4000-8000-000000000019", + "0000001a-0000-4000-8000-00000000001a", + "0000001b-0000-4000-8000-00000000001b", + "0000001c-0000-4000-8000-00000000001c", + "0000001d-0000-4000-8000-00000000001d", + "0000001e-0000-4000-8000-00000000001e", + "0000001f-0000-4000-8000-00000000001f", + "00000020-0000-4000-8000-000000000020", + "00000021-0000-4000-8000-000000000021", + "00000022-0000-4000-8000-000000000022", + "00000023-0000-4000-8000-000000000023", + "00000024-0000-4000-8000-000000000024", + "00000025-0000-4000-8000-000000000025", + "00000026-0000-4000-8000-000000000026", + "00000027-0000-4000-8000-000000000027", + "00000028-0000-4000-8000-000000000028", + "00000029-0000-4000-8000-000000000029", + "0000002a-0000-4000-8000-00000000002a", + "0000002b-0000-4000-8000-00000000002b", + "0000002c-0000-4000-8000-00000000002c", + "0000002d-0000-4000-8000-00000000002d", + "0000002e-0000-4000-8000-00000000002e", + "0000002f-0000-4000-8000-00000000002f", + "00000030-0000-4000-8000-000000000030", + "00000031-0000-4000-8000-000000000031", + "00000032-0000-4000-8000-000000000032", + "00000033-0000-4000-8000-000000000033", + "00000034-0000-4000-8000-000000000034", + "00000035-0000-4000-8000-000000000035", + "00000036-0000-4000-8000-000000000036", + "00000037-0000-4000-8000-000000000037", + "00000038-0000-4000-8000-000000000038", + "00000039-0000-4000-8000-000000000039", + "0000003a-0000-4000-8000-00000000003a", + "0000003b-0000-4000-8000-00000000003b", + "0000003c-0000-4000-8000-00000000003c", + "0000003d-0000-4000-8000-00000000003d", + "0000003e-0000-4000-8000-00000000003e", + "0000003f-0000-4000-8000-00000000003f", + "00000040-0000-4000-8000-000000000040", + "00000041-0000-4000-8000-000000000041" + ], + "decider_count": 65, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + } + ] + }, + "request": { + "valid": [ + { + "name": "choice_and_note", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "choice_alone", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000005-0000-4000-8000-000000000005" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030352D303030302D343030302D383030302D303030303030303030303035" + }, + { + "name": "note_alone_clear_pick", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000006-0000-4000-8000-000000000006" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030362D303030302D343030302D383030302D303030303030303030303036" + }, + { + "name": "retraction", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000024-0000-4000-8000-000000000024" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E73776572C0A9726574726163746564C3A87265747261637473A26E31AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032342D303030302D343030302D383030302D303030303030303030303234" + }, + { + "name": "note_4000_ascii_bytes", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000025-0000-4000-8000-000000000025" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA078787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032352D303030302D343030302D383030302D303030303030303030303235" + }, + { + "name": "note_astral_4000_bytes", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000026-0000-4000-8000-000000000026" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA0F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032362D303030302D343030302D383030302D303030303030303030303236" + }, + { + "name": "note_combining_4000_bytes", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééx" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000027-0000-4000-8000-000000000027" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA065CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8178A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032372D303030302D343030302D383030302D303030303030303030303237" + } + ], + "invalid": [ + { + "name": "answer_with_neither", + "why": "an answer object holds a choice, a note, or both", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000028-0000-4000-8000-000000000028" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032382D303030302D343030302D383030302D303030303030303030303238" + }, + { + "name": "blank_note", + "why": "a blank note is no answer", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "   " + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000029-0000-4000-8000-000000000029" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465A52020C2A020A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032392D303030302D343030302D383030302D303030303030303030303239" + }, + { + "name": "request_id_missing", + "why": "map-mode writes every key; a retry needs the id", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z" + }, + "messagePackHex": "87AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305A" + }, + { + "name": "request_id_uppercase", + "why": "request_id is lowercase", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000000A-0000-4000-8000-00000000000B" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030412D303030302D343030302D383030302D303030303030303030303042" + }, + { + "name": "request_id_not_a_uuid", + "why": "request_id is a lowercase D-format UUID", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "retry-1" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964A772657472792D31" + }, + { + "name": "note_over_4000", + "why": "notes are at most 4000 UTF-8 bytes", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000002a-0000-4000-8000-00000000002a" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA17878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032612D303030302D343030302D383030302D303030303030303030303261" + }, + { + "name": "note_multibyte_4001_bytes", + "why": "a note is counted in UTF-8 bytes: 1999 e-acute is 3998 bytes, plus three ASCII is 4001 (rule: not expressible in the schema, 2002 characters)", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééabc" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000002b-0000-4000-8000-00000000002b" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA1C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9C3A9616263A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032622D303030302D343030302D383030302D303030303030303030303262" + }, + { + "name": "note_astral_4004_bytes", + "why": "1001 astral characters are 4004 bytes though only 1001 code points (rule: not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000002c-0000-4000-8000-00000000002c" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA4F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880F09F9880A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032632D303030302D343030302D383030302D303030303030303030303263" + }, + { + "name": "note_combining_4002_bytes", + "why": "e plus a combining acute is 3 bytes: 1334 of them are 4002 (rule: not expressible in the schema)", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000002d-0000-4000-8000-00000000002d" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365C0A46E6F7465DA0FA265CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC8165CC81A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032642D303030302D343030302D383030302D303030303030303030303264" + }, + { + "name": "options_hash_uppercase", + "why": "hash is lowercase hex", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7D95FC87D532420611FE415C1DEF27A3447617BB68235F46E3AF130AEA361C48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000002e-0000-4000-8000-00000000002e" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037443935464338374435333234323036313146453431354331444546323741333434373631374242363832333546343645334146313330414541333631433438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032652D303030302D343030302D383030302D303030303030303030303265" + }, + { + "name": "answerer_key", + "why": "answerer is server-set; the door answers body_forbidden_field", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + }, + "messagePackHex": "89AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166" + }, + { + "name": "authority_key", + "why": "authority is server-set; the door answers body_forbidden_field", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "authority": "Operator" + }, + "messagePackHex": "89AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A9617574686F72697479A84F70657261746F72" + }, + { + "name": "answer_object_with_a_key", + "why": "the answer object is closed", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null, + "weight": 1 + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000002f-0000-4000-8000-00000000002f" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657283A663686F696365A26E6FA46E6F7465C0A677656967687401A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303032662D303030302D343030302D383030302D303030303030303030303266" + }, + { + "name": "answered_at_without_zone", + "why": "instants are UTC with a Z", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00", + "request_id": "00000030-0000-4000-8000-000000000030" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B3323032362D31312D31325431393A30323A3030AA726571756573745F6964D92430303030303033302D303030302D343030302D383030302D303030303030303030303330" + }, + { + "name": "retracted_not_a_boolean", + "why": "retracted is a boolean", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": "no", + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EA9726574726163746564A26E6FA87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "fork_id_empty", + "why": "fork_id names a fork", + "message": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000031-0000-4000-8000-000000000031" + }, + "messagePackHex": "88AD736368656D6156657273696F6EBF67616D6563756C742E666F726B2E616E737765725F726571756573742E7630A7666F726B5F6964A0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A6616E7377657282A663686F696365A26E6FA46E6F7465C0A9726574726163746564C2A87265747261637473C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAA726571756573745F6964D92430303030303033312D303030302D343030302D383030302D303030303030303030303331" + } + ] + }, + "deskAnswer": { + "valid": [ + { + "name": "counted", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000032-0000-4000-8000-000000000032" + }, + "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", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000033-0000-4000-8000-000000000033" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033332D303030302D343030302D383030302D303030303030303030303333" + }, + { + "name": "refused_closed_retraction_names_its_target", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "fork_closed", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000034-0000-4000-8000-000000000034" + }, + "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": [ + { + "name": "counted_with_a_code", + "why": "only Refused carries a code", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": "fork_closed", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "refused_without_a_code", + "why": "Refused carries a reason code", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n5", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "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:07:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E35A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30373A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "refused_with_an_unknown_code", + "why": "the code set is closed", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n5", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "because", + "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:07:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E35A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465A762656361757365AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30373A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "counted_naming_another_answer", + "why": "Counted names none", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": "n0", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66A26E30A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "revision_naming_none", + "why": "a Revision names the counted answer", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": null, + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "revision_that_also_retracts", + "why": "a variant carries only its own link (a retraction with no target, or a target with no retraction, cannot exist)", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": "n1", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "retraction_naming_no_target", + "why": "a Retraction names the answer it retracts", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n8", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Retraction", + "of": null, + "retracts": null, + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000035-0000-4000-8000-000000000035" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033352D303030302D343030302D383030302D303030303030303030303335" + }, + { + "name": "refused_naming_another_answer_as_of", + "why": "a Refused answer names no counted answer (`of` is a Revision's)", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n5", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": "n1", + "retracts": null, + "code": "fork_closed", + "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:07:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E35A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66A26E31A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30373A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "refused_with_a_target_that_is_not_an_answer_id", + "why": "a Refused answer's retracts is nil or an answer id (n), never the stored key", + "message": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n5", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "cultlib-gaps:desk_answer:slot-mark-consent.n1", + "code": "fork_closed", + "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:07:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E35A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473D92D63756C746C69622D676170733A6465736B5F616E737765723A736C6F742D6D61726B2D636F6E73656E742E6E31A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30373A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "retraction_carrying_a_choice", + "why": "a Retraction carries no choice or note", + "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": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000036-0000-4000-8000-000000000036" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303033362D303030302D343030302D383030302D303030303030303030303336" + }, + { + "name": "outcome_lower_case", + "why": "outcome tags are Counted, Revision, Retraction, Refused", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7636F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "no_answerer", + "why": "answerer is stamped on every stored answer", + "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": "", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "messagePackHex": "DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572A0A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + } + ] + }, + "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", + "httpStatus": 409, + "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": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A57469746C65B1536C6F74206D61726B20636F6E73656E74A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0" + }, + { + "name": "not_a_decider", + "httpStatus": 403, + "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": "Refused", + "of": null, + "retracts": null, + "code": "not_a_decider", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264C0B1636F756E7465645F616E737765725F6964C0" + }, + { + "name": "fork_closed", + "httpStatus": 409, + "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": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264C0B1636F756E7465645F616E737765725F6964C0" + }, + { + "name": "choice_invalid", + "httpStatus": 400, + "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": "Refused", + "of": null, + "retracts": null, + "code": "choice_invalid", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE63686F6963655F696E76616C6964AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264C0B1636F756E7465645F616E737765725F6964C0" + }, + { + "name": "bad_retraction", + "httpStatus": 400, + "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": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264C0B1636F756E7465645F616E737765725F6964C0" + }, + { + "name": "already_retracted", + "httpStatus": 409, + "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": "Refused", + "of": null, + "retracts": null, + "code": "already_retracted", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBE67616D6563756C742E666F726B2E616E737765725F726573756C742E7630A6616E73776572DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465B1616C72656164795F726574726163746564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031A463617264C0B1636F756E7465645F616E737765725F6964C0" + } + ], + "doorRefusals": [ + { + "name": "unauthenticated", + "httpStatus": 401, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "unauthenticated", + "error": "unauthenticated: a verified session is required" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AF756E61757468656E74696361746564A56572726F72D92F756E61757468656E746963617465643A20612076657269666965642073657373696F6E206973207265717569726564" + }, + { + "name": "body_forbidden_field", + "httpStatus": 400, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_forbidden_field", + "error": "body_forbidden_field: answerer and authority are set by the receiver" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B4626F64795F666F7262696464656E5F6669656C64A56572726F72D944626F64795F666F7262696464656E5F6669656C643A20616E73776572657220616E6420617574686F72697479206172652073657420627920746865207265636569766572" + }, + { + "name": "body_unknown_field", + "httpStatus": 400, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B2626F64795F756E6B6E6F776E5F6669656C64A56572726F72D92E626F64795F756E6B6E6F776E5F6669656C643A2074686520626F6479206973206120636C6F736564207368617065" + }, + { + "name": "body_invalid", + "httpStatus": 400, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_invalid", + "error": "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AC626F64795F696E76616C6964A56572726F72D947626F64795F696E76616C69643A2074686520626F647920646F6573206E6F7420736174697366792067616D6563756C742E666F726B2E616E737765725F726571756573742E7630" + }, + { + "name": "fork_unknown", + "httpStatus": 404, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_unknown", + "error": "fork_unknown: no such fork" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AC666F726B5F756E6B6E6F776EA56572726F72BA666F726B5F756E6B6E6F776E3A206E6F207375636820666F726B" + }, + { + "name": "insufficient_scope", + "httpStatus": 403, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "insufficient_scope", + "error": "insufficient_scope: the token lacks the scope this route needs" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B2696E73756666696369656E745F73636F7065A56572726F72D93E696E73756666696369656E745F73636F70653A2074686520746F6B656E206C61636B73207468652073636F7065207468697320726F757465206E65656473" + }, + { + "name": "forbidden", + "httpStatus": 403, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "forbidden", + "error": "forbidden: only Active members may use the desk" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465A9666F7262696464656EA56572726F72D92F666F7262696464656E3A206F6E6C7920416374697665206D656D62657273206D61792075736520746865206465736B" + }, + { + "name": "request_id_conflict", + "httpStatus": 409, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B3726571756573745F69645F636F6E666C696374A56572726F72D942726571756573745F69645F636F6E666C6963743A207468697320726571756573745F696420776173207573656420666F72206120646966666572656E7420626F6479" + }, + { + "name": "request_invalid", + "httpStatus": 400, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_invalid", + "error": "request_invalid: the request does not satisfy its document" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AF726571756573745F696E76616C6964A56572726F72D93A726571756573745F696E76616C69643A20746865207265717565737420646F6573206E6F7420736174697366792069747320646F63756D656E74" + }, + { + "name": "fork_too_large", + "httpStatus": 422, + "message": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget" + }, + "messagePackHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E736520627564676574" + } + ], + "unavailable": [ + { + "name": "second_door_down", + "httpStatus": 503, + "message": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" + }, + { + "name": "second_door_down_no_hint", + "httpStatus": 503, + "message": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": null + }, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E6473C0" + } + ], + "rulingCites": [ + { + "name": "operator_cites_a_counted_operator_answer", + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Operator", + "answer_id": "n1" + }, + "admit": true, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A84F70657261746F72A9616E737765725F6964A26E31" + }, + { + "name": "operator_cites_nothing", + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Operator", + "answer_id": null + }, + "admit": false, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A84F70657261746F72A9616E737765725F6964C0" + }, + { + "name": "operator_cites_a_revision", + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + } + ], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Operator", + "answer_id": "n2" + }, + "admit": false, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A84F70657261746F72A9616E737765725F6964A26E32" + }, + { + "name": "operator_cites_a_refused_answer", + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n5", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "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:07:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Operator", + "answer_id": "n5" + }, + "admit": false, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A84F70657261746F72A9616E737765725F6964A26E35" + }, + { + "name": "operator_cites_a_counted_answer_by_a_non_operator", + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n4", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share, please.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:03:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000004-0000-4000-8000-000000000004" + } + ], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Operator", + "answer_id": "n4" + }, + "admit": false, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A84F70657261746F72A9616E737765725F6964A26E34" + }, + { + "name": "operator_cites_an_answer_that_does_not_exist", + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Operator", + "answer_id": "n99" + }, + "admit": false, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A84F70657261746F72A9616E737765725F6964A36E3939" + }, + { + "name": "steward_needs_no_citation", + "answers": [], + "message": { + "schemaVersion": "gamecult.fork.ruling_cite.v0", + "ruling_id": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "authority": "Steward", + "answer_id": null + }, + "admit": true, + "messagePackHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E72756C696E675F636974652E7630A972756C696E675F6964D93563756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E736C6F742D6D61726B2D636F6E73656E742E6E31A9617574686F72697479A753746577617264A9616E737765725F6964C0" + } + ], + "transport": { + "listRequest": { + "valid": [ + { + "name": "list_open_page", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "open", + "cursor": null, + "limit": 2 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573A46F70656EA6637572736F72C0A56C696D697402" + }, + { + "name": "list_next_page", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "open", + "cursor": "c4", + "limit": 2 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573A46F70656EA6637572736F72A26334A56C696D697402" + }, + { + "name": "list_count_only", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 0 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573C0A6637572736F72C0A56C696D697400" + } + ], + "invalid": [ + { + "name": "limit_51", + "why": "a page is at most 50 forks", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 51 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573C0A6637572736F72C0A56C696D697433" + }, + { + "name": "limit_negative", + "why": "limit is 0 to 50", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": -1 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573C0A6637572736F72C0A56C696D6974FF" + }, + { + "name": "status_all", + "why": "status is open, recorded or nil", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "all", + "cursor": null, + "limit": 10 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573A3616C6CA6637572736F72C0A56C696D69740A" + }, + { + "name": "cursor_malformed", + "why": "a cursor is c", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "next", + "limit": 10 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573C0A6637572736F72A46E657874A56C696D69740A" + }, + { + "name": "cursor_zero", + "why": "a cursor names a positive order", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c0", + "limit": 10 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573C0A6637572736F72A26330A56C696D69740A" + }, + { + "name": "mind_empty", + "why": "a list names a Mind", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "", + "status": null, + "cursor": null, + "limit": 10 + }, + "messagePackHex": "85AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64A0A6737461747573C0A6637572736F72C0A56C696D69740A" + }, + { + "name": "unknown_key", + "why": "closed document", + "message": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 10, + "sort": "oldest" + }, + "messagePackHex": "86AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E6C6973745F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A6737461747573C0A6637572736F72C0A56C696D69740AA4736F7274A66F6C64657374" + } + ] + }, + "readRequest": { + "valid": [ + { + "name": "read_one", + "message": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:slot-mark-consent" + }, + "messagePackHex": "83AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" + } + ], + "invalid": [ + { + "name": "fork_id_empty", + "why": "a read names a fork", + "message": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", + "fork_id": "" + }, + "messagePackHex": "83AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A46D696E64AD67616D6563756C742D63686174A7666F726B5F6964A0" + }, + { + "name": "mind_empty", + "why": "a read names a Mind", + "message": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "", + "fork_id": "gamecult-chat:question:slot-mark-consent" + }, + "messagePackHex": "83AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A46D696E64A0A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" + }, + { + "name": "mind_missing", + "why": "map-mode writes every key; the Mind is the route's", + "message": { + "schemaVersion": "gamecult.fork.read_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent" + }, + "messagePackHex": "82AD736368656D6156657273696F6EBD67616D6563756C742E666F726B2E726561645F726571756573742E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74" + } + ] + }, + "entry": { + "valid": [ + { + "name": "open_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": [ + "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": [] + } + }, + "messagePackHex": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "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": [ + "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": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "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": [ + "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" + } + ] + } + }, + "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": [ + { + "name": "entry_without_record", + "why": "an entry holds the card and its record", + "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" + } + }, + "messagePackHex": "82AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162" + }, + { + "name": "record_of_another_fork", + "why": "rule: the record names the card's fork (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:q9", + "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": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7139AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "record_under_another_hash", + "why": "rule: the record is under the card's options_hash (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": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "83AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D303030303030303030303230" + }, + { + "name": "counted_answer_not_derived", + "why": "rule: counted_answer_id is the first live Counted or Revision answer under the card's hash (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": null, + "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": "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", + "why": "rule: the server computes options_hash and a client recomputes it; a card with another hash is no card", + "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": [ + "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 4, + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q4", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + "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" + } + ] + }, + "listInvalid": [ + { + "name": "next_cursor_malformed", + "why": "a cursor is c", + "message": { + "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": [ + "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": [] + } + } + ], + "next_cursor": "more" + }, + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A46D6F7265" + }, + { + "name": "stamp_malformed", + "why": "a stamp is s", + "message": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "412", + "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": [ + "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": [] + } + } + ], + "next_cursor": "c4" + }, + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A3343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26334" + }, + { + "name": "count_negative", + "why": "counts are not negative", + "message": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s412", + "open_count": -1, + "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": [] + } + } + ], + "next_cursor": "c4" + }, + "messagePackHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E74FFAE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26334" + }, + { + "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": 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": [ + "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: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": "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" + } + ], + "listScenarios": [ + { + "name": "newest_first_paged_open_only", + "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": [ + "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: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": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "open", + "cursor": null, + "limit": 2 + }, + "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": [ + "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": [] + } + } + ], + "next_cursor": "c4" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26334" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "open", + "cursor": "c4", + "limit": 2 + }, + "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: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": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "all_forks_one_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": [ + "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: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": [] + } + } + ], + "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": [ + "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: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 + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739583AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A57469746C65A6466F726B2035A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657205AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7135A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94031366432306136646334346236636330306166643337386539663063386235316466653562303765383639653161326161393538386465316465353837666162A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032302D303030302D343030302D383030302D30303030303030303030323083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134A57469746C65A6466F726B2034A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657204AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7134AC6F7074696F6E735F68617368D94061363436303432333766393036623666656339636232393238363336383564393636616266383034386632373661666132346364346631653163346337346161B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D30303030303030303030323283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "recorded_only_paged", + "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": [ + "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: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": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "recorded", + "cursor": null, + "limit": 1 + }, + "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: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" + } + ] + } + } + ], + "next_cursor": "c3" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71332E6E31AA72756C65645F66726F6D91A26E31A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A3796573A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303032312D303030302D343030302D383030302D303030303030303030303231DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30343A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303032322D303030302D343030302D383030302D303030303030303030303232AB6E6578745F637572736F72A26333" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": "recorded", + "cursor": "c3", + "limit": 1 + }, + "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: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 + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A6636C6F736564A672756C696E67D92663756C746C69622D676170733A7265736F6C7574696F6E3A7175657374696F6E2E71312E6E31AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "limit_zero_is_the_count_and_stamp_only", + "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": [ + "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: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": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 0 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s412", + "open_count": 3, + "recorded_count": 2, + "forks": [], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A473343132AA6F70656E5F636F756E7403AE7265636F726465645F636F756E7402A5666F726B7390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "order_zero_is_legal_but_no_cursor_names_it", + "ordinal": 9, + "entries": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": "open", + "ruling": null, + "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": "c1" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26331" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c1", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "next_cursor": null + }, + "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, + "entries": [], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "nibu", + "status": null, + "cursor": null, + "limit": 10 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "nibu", + "stamp": "s7", + "open_count": 0, + "recorded_count": 0, + "forks": [], + "next_cursor": null + }, + "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": [ + { + "name": "a_short_page_resumes_below_its_last_entry", + "ordinal": 9, + "entries": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": [] + } + } + ], + "next_cursor": "c2" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133A57469746C65A6466F726B2033A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657203AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7133AC6F7074696F6E735F68617368D94063656438393265623636336332326533643765636536303237303830343365653534356635366239643465343432316437663562666161383663343138636532B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26332" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c2", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739283AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "a_first_entry_past_the_budget_is_fork_too_large_and_the_client_continues_below_its_order", + "ordinal": 9, + "entries": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 3 + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 (order 3) alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320286F7264657220332920616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q3", + "order": 3 + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c3", + "limit": 3 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 4, + "recorded_count": 0, + "forks": [ + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "next_cursor": null + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7404AE7265636F726465645F636F756E7400A5666F726B739383AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132A57469746C65A6466F726B2032A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657202AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7132AC6F7074696F6E735F68617368D94035633763323934356538653836383963366339323862313164376530613033333634306230633636616662376663666662396537343063303139643633653739B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E73776572739083AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130A57469746C65A6466F726B2030A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657200AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7130AC6F7074696F6E735F68617368D94031373737626430366666616664663665383162656435393739663337313933633462346537633364323530346639303465393133313031326630373637613964B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72C0" + } + ] + }, + { + "name": "fork_too_large_on_order_zero_leaves_nothing_below", + "ordinal": 9, + "entries": [ + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "steps": [ + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": null, + "limit": 5 + }, + "response": { + "schemaVersion": "gamecult.fork.list.v0", + "mind": "gamecult-chat", + "stamp": "s9", + "open_count": 2, + "recorded_count": 0, + "forks": [ + { + "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": "open", + "ruling": null, + "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": "c1" + }, + "responseHex": "87AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E6C6973742E7630A46D696E64AD67616D6563756C742D63686174A57374616D70A27339AA6F70656E5F636F756E7402AE7265636F726465645F636F756E7400A5666F726B739183AD736368656D6156657273696F6EB667616D6563756C742E666F726B2E656E7472792E7630A463617264DE0012AD736368656D6156657273696F6EB567616D6563756C742E666F726B2E636172642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131A57469746C65A6466F726B2031A7636F6E74657874BD53686F756C64206120736C6F74206D61726B206265207368617265643FA76F7074696F6E739282A26964A3796573A474657874A953686172652069742E82A26964A26E6FA474657874AE4B656570206974206C6F63616C2EAE7265636F6D6D656E646174696F6EA26E6FA7646570656E647391D92967616D6563756C742D636861743A7175657374696F6E3A66696C652D7A6F6E652D61756469656E6365A56C65616E73C0A4666C6167C0A97261697365645F696E82A46B696E64A743757453706563A26964D92B63756C746C69622D676170733A6375745F737065633A6375742D736C6F742D746F6C6572616E63652E7234A97261697365645F6174B4323032362D31312D31305430393A33303A30305AA8646563696465727391D92465306431633262332D613439352D343638372D383837392D366135623463336432653166AD646563696465725F636F756E7401A6737461747573A46F70656EA672756C696E67C0AA72756C65645F66726F6D90A56F7264657201AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361A67265636F726486AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964B967616D6563756C742D636861743A7175657374696F6E3A7131AC6F7074696F6E735F68617368D94036313935393936346264323134656131623936313535346334336563343661396661663135366464353437636335306338633136626264636435356632323361B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390AB6E6578745F637572736F72A26331" + }, + { + "request": { + "schemaVersion": "gamecult.fork.list_request.v0", + "mind": "gamecult-chat", + "status": null, + "cursor": "c1", + "limit": 5 + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q0 (order 0) alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D95A666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713020286F7264657220302920616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q0", + "order": 0 + } + ] + } + ], + "readTooLarge": [ + { + "name": "a_read_whose_entry_is_past_the_cap_is_fork_too_large_without_an_order", + "ordinal": 9, + "entries": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "request": { + "schemaVersion": "gamecult.fork.read_request.v0", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:q3" + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D950666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "fork_id": "gamecult-chat:question:q3" + } + ], + "answerTooLarge": [ + { + "name": "a_stale_answer_whose_card_reread_is_past_the_cap_is_fork_too_large_over_a_stored_stale_refusal", + "entries": [ + { + "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": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "ced892eb663c22e3d7ece602708043ee545f56b9d4e4421d7f5bfaa86c418ce2", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + }, + { + "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": "open", + "ruling": null, + "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": [] + } + }, + { + "schemaVersion": "gamecult.fork.entry.v0", + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:q0", + "title": "Fork 0", + "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": 0, + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d" + }, + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:q0", + "options_hash": "1777bd06ffafdf6e81bed5979f37193c4b4e7c3d2504f904e9131012f0767a9d", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + } + } + ], + "request": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:q3", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "yes", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "refusal": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_too_large", + "error": "fork_too_large: fork gamecult-chat:question:q3 alone is past the response budget" + }, + "refusalHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465AE666F726B5F746F6F5F6C61726765A56572726F72D950666F726B5F746F6F5F6C617267653A20666F726B2067616D6563756C742D636861743A7175657374696F6E3A713320616C6F6E6520697320706173742074686520726573706F6E736520627564676574", + "httpStatus": 422, + "outcome": "Refused", + "code": "stale_fork", + "counted": false, + "fork_id": "gamecult-chat:question:q3" + } + ], + "doorFaults": [ + { + "name": "read_door_answers_request_id_conflict_the_client_sees_unavailable", + "route": "read", + "doorCode": "request_id_conflict", + "httpStatus": 503, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "responseHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" + }, + { + "name": "list_door_answers_request_id_conflict_the_client_sees_unavailable", + "route": "list", + "doorCode": "request_id_conflict", + "httpStatus": 503, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + }, + "responseHex": "84AD736368656D6156657273696F6EBC67616D6563756C742E666F726B2E756E617661696C61626C652E7630A4636F6465AB756E617661696C61626C65A56572726F72D94D756E617661696C61626C653A20746865206F7574636F6D65206973206E6F7420636F6E6669726D65642C2074727920616761696E2077697468207468652073616D6520726571756573745F6964B1726574727941667465725365636F6E64731E" + }, + { + "name": "answer_door_answers_request_id_conflict_the_client_sees_it", + "route": "answer", + "doorCode": "request_id_conflict", + "httpStatus": 409, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + }, + "responseHex": "83AD736368656D6156657273696F6EB867616D6563756C742E666F726B2E7265667573616C2E7630A4636F6465B3726571756573745F69645F636F6E666C696374A56572726F72D942726571756573745F69645F636F6E666C6963743A207468697320726571756573745F696420776173207573656420666F72206120646966666572656E7420626F6479" + } + ], + "mindRoutes": { + "minds": { + "gamecult-chat": "huginn-gamecult-chat.sock", + "nibu": "huginn-nibu.sock" + }, + "forks": { + "gamecult-chat": [ + "gamecult-chat:question:q1", + "gamecult-chat:question:q2", + "gamecult-chat:question:slot-mark-consent" + ], + "nibu": [ + "nibu:question:glow-order" + ] + }, + "cases": [ + { + "name": "list_a_percent_encoded_configured_mind_is_decoded_once", + "route": "list", + "mind": "%6eibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "list_a_configured_mind", + "route": "list", + "mind": "gamecult-chat", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "list_another_configured_mind", + "route": "list", + "mind": "nibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "read_a_fork_the_mind_holds", + "route": "read", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "answer_a_fork_the_mind_holds_is_stored_in_that_mind", + "route": "answer", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-gamecult-chat.sock", + "stored_in": "gamecult-chat" + }, + { + "name": "answer_on_the_other_mind", + "route": "answer", + "mind": "nibu", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": "nibu" + }, + { + "name": "M1_list_dot_dot_is_unknown_and_opens_nothing", + "route": "list", + "mind": "../x", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_slash_is_unknown_and_opens_nothing", + "route": "list", + "mind": "a/b", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_encoded_slash_is_unknown_and_opens_nothing", + "route": "list", + "mind": "a%2fb", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_encoded_dot_dot_is_unknown_and_opens_nothing", + "route": "list", + "mind": "%2e%2e%2fx", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_nul_is_unknown_and_opens_nothing", + "route": "list", + "mind": "eureka%00", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_four_kb_is_unknown_and_opens_nothing", + "route": "list", + "mind": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_other_case_is_unknown_and_opens_nothing", + "route": "list", + "mind": "GAMECULT-CHAT", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_trailing_space_is_unknown_and_opens_nothing", + "route": "list", + "mind": "nibu%20", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_invalid_utf8_is_unknown_and_opens_nothing", + "route": "list", + "mind": "%ff", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_dot_is_unknown_and_opens_nothing", + "route": "list", + "mind": ".", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_dots_is_unknown_and_opens_nothing", + "route": "list", + "mind": "..", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_list_double_encoded_is_unknown_and_opens_nothing", + "route": "list", + "mind": "%256eibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_dot_dot_is_unknown_and_opens_nothing", + "route": "read", + "mind": "../x", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_slash_is_unknown_and_opens_nothing", + "route": "read", + "mind": "a/b", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_encoded_slash_is_unknown_and_opens_nothing", + "route": "read", + "mind": "a%2fb", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_encoded_dot_dot_is_unknown_and_opens_nothing", + "route": "read", + "mind": "%2e%2e%2fx", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_nul_is_unknown_and_opens_nothing", + "route": "read", + "mind": "eureka%00", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_four_kb_is_unknown_and_opens_nothing", + "route": "read", + "mind": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_other_case_is_unknown_and_opens_nothing", + "route": "read", + "mind": "GAMECULT-CHAT", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_trailing_space_is_unknown_and_opens_nothing", + "route": "read", + "mind": "nibu%20", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_invalid_utf8_is_unknown_and_opens_nothing", + "route": "read", + "mind": "%ff", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_dot_is_unknown_and_opens_nothing", + "route": "read", + "mind": ".", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_dots_is_unknown_and_opens_nothing", + "route": "read", + "mind": "..", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_read_double_encoded_is_unknown_and_opens_nothing", + "route": "read", + "mind": "%256eibu", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_dot_dot_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "../x", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_slash_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "a/b", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_encoded_slash_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "a%2fb", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_encoded_dot_dot_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "%2e%2e%2fx", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_nul_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "eureka%00", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_four_kb_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_other_case_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "GAMECULT-CHAT", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_trailing_space_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "nibu%20", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_invalid_utf8_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "%ff", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_dot_is_unknown_and_opens_nothing", + "route": "answer", + "mind": ".", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_dots_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "..", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M1_answer_double_encoded_is_unknown_and_opens_nothing", + "route": "answer", + "mind": "%256eibu", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M2_read_unknown_mind_known_fork", + "route": "read", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M2_read_known_mind_unknown_fork", + "route": "read", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:ghost", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "M2_answer_unknown_mind_known_fork", + "route": "answer", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M2_answer_known_mind_unknown_fork", + "route": "answer", + "mind": "gamecult-chat", + "fork_id": "gamecult-chat:question:ghost", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "M2_list_unknown_mind", + "route": "list", + "mind": "ghost", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": null, + "stored_in": null + }, + { + "name": "M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", + "route": "answer", + "mind": "nibu", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "M3_answer_on_gamecult_chat_with_a_fork_nibu_holds", + "route": "answer", + "mind": "gamecult-chat", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "fork_unknown", + "httpStatus": 404, + "opened": "huginn-gamecult-chat.sock", + "stored_in": null + }, + { + "name": "M4_desk_read_lists_every_mind", + "route": "list", + "mind": "nibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "M4_desk_read_reads_every_mind", + "route": "read", + "mind": "nibu", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "httpStatus": 200, + "opened": "huginn-nibu.sock", + "stored_in": null + }, + { + "name": "M4_desk_answer_cannot_list_any_mind", + "route": "list", + "mind": "gamecult-chat", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "M4_desk_answer_cannot_list_the_other_mind", + "route": "list", + "mind": "nibu", + "fork_id": null, + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "M4_desk_read_cannot_answer_on_any_mind", + "route": "answer", + "mind": "nibu", + "fork_id": "nibu:question:glow-order", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "auth_comes_before_the_mind_lookup_no_bearer", + "route": "list", + "mind": "ghost", + "fork_id": null, + "token": null, + "expect": "unauthenticated", + "httpStatus": 401, + "opened": null, + "stored_in": null + }, + { + "name": "auth_comes_before_the_mind_lookup_scope", + "route": "answer", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "insufficient_scope", + "httpStatus": 403, + "opened": null, + "stored_in": null + }, + { + "name": "auth_comes_before_the_mind_lookup_non_active", + "route": "read", + "mind": "ghost", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": false + }, + "expect": "forbidden", + "httpStatus": 403, + "opened": null, + "stored_in": null + } + ], + "sameResponse": [ + [ + "M2_read_unknown_mind_known_fork", + "M2_read_known_mind_unknown_fork" + ], + [ + "M2_answer_unknown_mind_known_fork", + "M2_answer_known_mind_unknown_fork" + ], + [ + "M2_list_unknown_mind", + "M2_read_known_mind_unknown_fork" + ], + [ + "M3_answer_on_nibu_with_a_fork_gamecult_chat_holds", + "M2_answer_known_mind_unknown_fork" + ] + ] + }, + "authCases": [ + { + "name": "list_with_desk_read", + "route": "list", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "huginn_down": false + }, + { + "name": "read_with_desk_read", + "route": "read", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "ok", + "huginn_down": false + }, + { + "name": "answer_with_desk_answer", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "ok", + "huginn_down": false + }, + { + "name": "answer_with_both_scopes", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read", + "desk.answer" + ], + "active": true + }, + "expect": "ok", + "huginn_down": false + }, + { + "name": "no_bearer_list", + "route": "list", + "token": null, + "expect": "unauthenticated", + "huginn_down": false + }, + { + "name": "no_bearer_answer", + "route": "answer", + "token": null, + "expect": "unauthenticated", + "huginn_down": false + }, + { + "name": "the_MAS_token_is_refused", + "route": "list", + "token": { + "audience": "matrix-authentication-service", + "scopes": [ + "openid", + "profile" + ], + "active": true + }, + "expect": "unauthenticated", + "huginn_down": false + }, + { + "name": "a_votes_token_cannot_list", + "route": "list", + "token": { + "audience": "bifrost-votes", + "scopes": [ + "votes.read" + ], + "active": true + }, + "expect": "unauthenticated", + "huginn_down": false + }, + { + "name": "a_votes_token_cannot_read", + "route": "read", + "token": { + "audience": "bifrost-votes", + "scopes": [ + "votes.read" + ], + "active": true + }, + "expect": "unauthenticated", + "huginn_down": false + }, + { + "name": "a_votes_token_cannot_answer", + "route": "answer", + "token": { + "audience": "bifrost-votes", + "scopes": [ + "votes.cast" + ], + "active": true + }, + "expect": "unauthenticated", + "huginn_down": false + }, + { + "name": "desk_audience_with_a_votes_scope_only", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "votes.cast" + ], + "active": true + }, + "expect": "insufficient_scope", + "huginn_down": false + }, + { + "name": "desk_read_cannot_answer", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "insufficient_scope", + "huginn_down": false + }, + { + "name": "desk_answer_cannot_list", + "route": "list", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "insufficient_scope", + "huginn_down": false + }, + { + "name": "a_non_active_member_cannot_list", + "route": "list", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": false + }, + "expect": "forbidden", + "huginn_down": false + }, + { + "name": "a_non_active_member_cannot_read", + "route": "read", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": false + }, + "expect": "forbidden", + "huginn_down": false + }, + { + "name": "a_non_active_member_cannot_answer", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": false + }, + "expect": "forbidden", + "huginn_down": false + }, + { + "name": "scope_is_judged_before_membership", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": false + }, + "expect": "insufficient_scope", + "huginn_down": false + }, + { + "name": "huginn_down_list", + "route": "list", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "unavailable", + "huginn_down": true + }, + { + "name": "huginn_down_read", + "route": "read", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": true + }, + "expect": "unavailable", + "huginn_down": true + }, + { + "name": "huginn_down_answer", + "route": "answer", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.answer" + ], + "active": true + }, + "expect": "unavailable", + "huginn_down": true + }, + { + "name": "auth_is_judged_before_huginn_is_asked", + "route": "list", + "token": null, + "expect": "unauthenticated", + "huginn_down": true + }, + { + "name": "membership_is_judged_before_huginn_is_asked", + "route": "read", + "token": { + "audience": "bifrost-desk", + "scopes": [ + "desk.read" + ], + "active": false + }, + "expect": "forbidden", + "huginn_down": true + } + ] + }, + "scenarios": [ + { + "name": "operator_answers_and_is_counted", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "choice_alone_is_counted", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000005-0000-4000-8000-000000000005" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": null, + "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": "00000005-0000-4000-8000-000000000005" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": null, + "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": "00000005-0000-4000-8000-000000000005" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030352D303030302D343030302D383030302D303030303030303030303035" + }, + { + "name": "note_alone_is_counted_a_clear_pick", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000006-0000-4000-8000-000000000006" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": null, + "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": "00000006-0000-4000-8000-000000000006" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": null, + "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": "00000006-0000-4000-8000-000000000006" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030362D303030302D343030302D383030302D303030303030303030303036" + }, + { + "name": "V1_answerer_in_the_body_is_refused_at_the_door_and_stores_nothing", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "answerer": "x" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_forbidden_field", + "error": "body_forbidden_field: answerer and authority are set by the receiver" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "V1_authority_nested_in_answer_is_refused", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null, + "authority": "Operator" + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_forbidden_field", + "error": "body_forbidden_field: answerer and authority are set by the receiver" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "V1_both_are_refused_not_stripped", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_forbidden_field", + "error": "body_forbidden_field: answerer and authority are set by the receiver" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "M5_a_body_naming_deciders_is_an_unknown_key_the_decider_list_is_never_the_bodys", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "deciders": [ + "11111111-2222-4333-8444-555555555555" + ] + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "M1_a_body_naming_a_mind_is_an_unknown_key_the_mind_is_the_routes", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "mind": "nibu" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "an_unknown_key_is_refused_the_body_is_closed", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "mood": "happy" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "an_answer_with_neither_choice_nor_note_is_invalid", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": null, + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000008-0000-4000-8000-000000000008" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_invalid", + "error": "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "a_non_retraction_without_an_answer_is_invalid", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000009-0000-4000-8000-000000000009" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_invalid", + "error": "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "unavailable_before_huginn_stored_nothing_is_retried_into_a_Counted_answer", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z", + "door_down": true, + "retry_after_seconds": 30 + }, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "unavailable_after_huginn_stored_the_retry_with_the_same_request_id_returns_the_stored_outcome", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z", + "door_down": true, + "stored": true, + "retry_after_seconds": 30 + }, + "response": { + "schemaVersion": "gamecult.fork.unavailable.v0", + "code": "unavailable", + "error": "unavailable: the outcome is not confirmed, try again with the same request_id", + "retryAfterSeconds": 30 + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "a_retry_never_adds_a_Revision", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "Q1_a_replayed_stale_fork_returns_the_CURRENT_card_and_stores_nothing_new", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000007-0000-4000-8000-000000000007" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000007-0000-4000-8000-000000000007" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": null + } + }, + { + "kind": "card", + "body": null, + "server": null, + "response": null, + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent (revised)", + "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": 3, + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000007-0000-4000-8000-000000000007" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000007-0000-4000-8000-000000000007" + }, + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent (revised)", + "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": 3, + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7" + }, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000007-0000-4000-8000-000000000007" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030372D303030302D343030302D383030302D303030303030303030303037" + }, + { + "name": "V13_only_answered_at_changed_is_request_id_conflict", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:03:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V14_an_identical_body_from_another_account_gets_its_own_Counted_and_never_the_first_accounts_outcome", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "not_a_decider", + "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": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n2", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:03:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "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:03:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n2" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "received_at": "2026-11-12T19:04:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "not_a_decider", + "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": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n2" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n2", + "answerer_count": 2, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "not_a_decider", + "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": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "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:03:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V14_an_identical_body_from_another_decider_is_its_own_Revision_and_the_first_retry_still_returns_n1", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n3", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:05:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "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:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "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:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V16_Q5_a_forbidden_request_binds_no_request_id", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z", + "active": false + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "forbidden", + "error": "forbidden: only Active members may use the desk" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V17_Q6_a_body_refused_at_the_door_binds_no_request_id", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "mood": "happy" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_unknown_field", + "error": "body_unknown_field: the body is a closed shape" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V17_Q6_an_unknown_fork_binds_no_request_id", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:nope", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_unknown", + "error": "fork_unknown: no such fork" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "the_same_request_id_with_another_body_is_request_id_conflict", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "the_same_request_id_from_another_account_is_another_request", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "Share it, the audience is settled." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:05:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n3", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:05:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "a_non_active_member_is_forbidden_before_the_body_is_read", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "answerer": "x" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z", + "active": false + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "forbidden", + "error": "forbidden: only Active members may use the desk" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z", + "active": false + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "forbidden", + "error": "forbidden: only Active members may use the desk" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001", + "mood": "happy" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z", + "active": false, + "door_down": true, + "retry_after_seconds": 30 + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "forbidden", + "error": "forbidden: only Active members may use the desk" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "no_session_is_unauthenticated", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": null, + "authority": null, + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "unauthenticated", + "error": "unauthenticated: a verified session is required" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "an_unknown_fork_is_refused_at_the_door", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:nope", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000000a-0000-4000-8000-00000000000a" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "fork_unknown", + "error": "fork_unknown: no such fork" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 0, + "answers": [] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7400A7616E737765727390" + }, + { + "name": "V2_stale_hash_is_refused_not_counted_and_stored_as_Refused", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000007-0000-4000-8000-000000000007" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000007-0000-4000-8000-000000000007" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000007-0000-4000-8000-000000000007" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030372D303030302D343030302D383030302D303030303030303030303037" + }, + { + "name": "V2_a_stale_answer_then_a_fresh_one_the_fresh_one_is_counted", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000000b-0000-4000-8000-00000000000b" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "0000000b-0000-4000-8000-00000000000b" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": null + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:06:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "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:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n2" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "0000000b-0000-4000-8000-00000000000b" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "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:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030622D303030302D343030302D383030302D303030303030303030303062DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V3_an_answer_off_the_decider_list_is_Refused_not_a_decider_and_stored", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "Share, please." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000004-0000-4000-8000-000000000004" + }, + "server": { + "answer_id": "n4", + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "received_at": "2026-11-12T19:03:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n4", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "not_a_decider", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share, please.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:03:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000004-0000-4000-8000-000000000004" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n4", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "not_a_decider", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share, please.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:03:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000004-0000-4000-8000-000000000004" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E34A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465AE53686172652C20706C656173652EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030342D303030302D343030302D383030302D303030303030303030303034" + }, + { + "name": "a_non_decider_with_a_stale_hash_is_stale_first", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "yes", + "note": "Share, please." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000004-0000-4000-8000-000000000004" + }, + "server": { + "answer_id": "n4", + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "received_at": "2026-11-12T19:03:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n4", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "choice": "yes", + "note": "Share, please.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:03:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000004-0000-4000-8000-000000000004" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n4", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "choice": "yes", + "note": "Share, please.", + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:03:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "00000004-0000-4000-8000-000000000004" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E34A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A3796573A46E6F7465AE53686172652C20706C656173652EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303030342D303030302D343030302D383030302D303030303030303030303034" + }, + { + "name": "V4_a_closed_fork_refuses_and_stores_the_refusal", + "card": { + "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" + ], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "fork_closed", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V5_second_answer_from_one_decider_is_a_Revision_the_first_stays_counted", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "On reflection, share it." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:06:00Z", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:06:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "V5_receiver_order_decides_not_answered_at", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "On reflection, share it." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:06:00Z", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "yes", + "note": "On reflection, share it.", + "answered_at": "2026-11-12T19:06:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:03:05Z" + }, + "response": { + "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": "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:03:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "yes", + "note": "On reflection, share it.", + "answered_at": "2026-11-12T19:06:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + { + "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": "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:03:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V5_reversed_arrival_reverses_the_result", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "On reflection, share it." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:06:00Z", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:03:05Z" + }, + "response": { + "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:03:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "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:03:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30333A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "a_second_decider_answer_is_recorded_as_a_Revision", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "Share it, the audience is settled." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:05:00Z", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + "server": { + "answer_id": "n3", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:05:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000003-0000-4000-8000-000000000003" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033" + }, + { + "name": "a_claim_exactly_five_minutes_ahead_is_not_flagged", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:07:05Z", + "request_id": "0000000c-0000-4000-8000-00000000000c" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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:07:05Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000000c-0000-4000-8000-00000000000c" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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:07:05Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000000c-0000-4000-8000-00000000000c" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30373A30355AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030632D303030302D343030302D383030302D303030303030303030303063" + }, + { + "name": "a_claim_five_minutes_and_half_a_second_ahead_is_flagged_from_the_stored_received_at", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:07:05.500Z", + "request_id": "0000000d-0000-4000-8000-00000000000d" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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:07:05.500Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": true, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000000d-0000-4000-8000-00000000000d" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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:07:05.500Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": true, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000000d-0000-4000-8000-00000000000d" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B8323032362D31312D31325431393A30373A30352E3530305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C3A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030642D303030302D343030302D383030302D303030303030303030303064" + }, + { + "name": "a_claim_more_than_five_minutes_ahead_is_accepted_and_flagged", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000000e-0000-4000-8000-00000000000e" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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:09:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": true, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000000e-0000-4000-8000-00000000000e" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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:09:00Z", + "received_at": "2026-11-12T19:02:05Z", + "claim_in_future": true, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000000e-0000-4000-8000-00000000000e" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C3A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030652D303030302D343030302D383030302D303030303030303030303065" + }, + { + "name": "V6_a_matrix_edit_redaction_or_reaction_changes_nothing", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "matrix_event", + "body": null, + "server": null, + "response": null, + "matrix_kind": "m.replace", + "choice": "yes" + }, + { + "kind": "matrix_event", + "body": null, + "server": null, + "response": null, + "matrix_kind": "m.redaction", + "choice": null + }, + { + "kind": "matrix_event", + "body": null, + "server": null, + "response": null, + "matrix_kind": "m.reaction", + "choice": "yes" + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V7_a_choice_that_is_not_an_option_is_Refused_choice_invalid", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "maybe", + "note": null + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000000f-0000-4000-8000-00000000000f" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "choice_invalid", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "maybe", + "note": null, + "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": "0000000f-0000-4000-8000-00000000000f" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "choice_invalid", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "maybe", + "note": null, + "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": "0000000f-0000-4000-8000-00000000000f" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE63686F6963655F696E76616C6964AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A56D61796265A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030662D303030302D343030302D383030302D303030303030303030303066" + }, + { + "name": "a_changed_card_stops_the_old_answers_counting_and_a_new_answer_is_counted", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "card", + "body": null, + "server": null, + "response": null, + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent (revised)", + "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": 3, + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000010-0000-4000-8000-000000000010" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:06:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", + "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:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000010-0000-4000-8000-000000000010" + }, + "card": null, + "counted_answer_id": "n2" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "retracts": null, + "code": null, + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", + "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:06:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000010-0000-4000-8000-000000000010" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031302D303030302D343030302D383030302D303030303030303030303130" + }, + { + "name": "a_changed_card_leaves_no_counted_answer_until_one_arrives", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "card", + "body": null, + "server": null, + "response": null, + "card": { + "schemaVersion": "gamecult.fork.card.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "title": "Slot mark consent (revised)", + "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": 3, + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "e7696ae1d487f78b5dc99fb8ccbd684d6e6f1ea7d7e190c21f3c6b4d8c42aaf7", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94065373639366165316434383766373862356463393966623863636264363834643665366631656137643765313930633231663363366234643863343261616637B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727391DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031" + }, + { + "name": "V8_retraction_with_an_answer_is_bad", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000011-0000-4000-8000-000000000011" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000011-0000-4000-8000-000000000011" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000011-0000-4000-8000-000000000011" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031312D303030302D343030302D383030302D303030303030303030303131" + }, + { + "name": "V8_retraction_without_retracts_is_bad", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000012-0000-4000-8000-000000000012" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000012-0000-4000-8000-000000000012" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000012-0000-4000-8000-000000000012" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031322D303030302D343030302D383030302D303030303030303030303132" + }, + { + "name": "V8_retracts_on_a_plain_answer_is_bad", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "On reflection, share it." + }, + "retracted": false, + "retracts": "n1", + "answered_at": "2026-11-12T19:06:00Z", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "On reflection, share it.", + "answered_at": "2026-11-12T19:06:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "On reflection, share it.", + "answered_at": "2026-11-12T19:06:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "V8_retraction_naming_an_unknown_answer_is_bad", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n99", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000013-0000-4000-8000-000000000013" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n99", + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000013-0000-4000-8000-000000000013" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n99", + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000013-0000-4000-8000-000000000013" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A36E3939A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031332D303030302D343030302D383030302D303030303030303030303133" + }, + { + "name": "V8_retraction_naming_a_Refused_answer_is_bad", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000014-0000-4000-8000-000000000014" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000014-0000-4000-8000-000000000014" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": null + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000015-0000-4000-8000-000000000015" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000015-0000-4000-8000-000000000015" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n1", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "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": "00000014-0000-4000-8000-000000000014" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000015-0000-4000-8000-000000000015" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031342D303030302D343030302D383030302D303030303030303030303134DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031352D303030302D343030302D383030302D303030303030303030303135" + }, + { + "name": "V8_retracting_twice_is_already_retracted", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "00000016-0000-4000-8000-000000000016" + }, + "server": { + "answer_id": "n8", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000016-0000-4000-8000-000000000016" + }, + "card": null, + "counted_answer_id": null + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "00000017-0000-4000-8000-000000000017" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:10:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "already_retracted", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:10:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000017-0000-4000-8000-000000000017" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000016-0000-4000-8000-000000000016" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "already_retracted", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:10:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000017-0000-4000-8000-000000000017" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031362D303030302D343030302D383030302D303030303030303030303136DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465B1616C72656164795F726574726163746564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A31303A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031372D303030302D343030302D383030302D303030303030303030303137" + }, + { + "name": "V8_retraction_under_a_stale_hash_is_stale_fork", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000018-0000-4000-8000-000000000018" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000018-0000-4000-8000-000000000018" + }, + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "stale_fork", + "options_hash": "14b8ffbdb61106dbc06f69f3fc8e2072af7a205d766e4cc040e89733013d54dc", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000018-0000-4000-8000-000000000018" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AA7374616C655F666F726BAC6F7074696F6E735F68617368D94031346238666662646236313130366462633036663639663366633865323037326166376132303564373636653463633034306538393733333031336435346463A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031382D303030302D343030302D383030302D303030303030303030303138" + }, + { + "name": "V9_another_decider_retracts_and_the_answer_stops_counting", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "00000019-0000-4000-8000-000000000019" + }, + "server": { + "answer_id": "n7", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n7", + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000019-0000-4000-8000-000000000019" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n7", + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000019-0000-4000-8000-000000000019" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E37A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303031392D303030302D343030302D383030302D303030303030303030303139" + }, + { + "name": "V9_an_account_off_the_list_cannot_retract", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000001a-0000-4000-8000-00000000001a" + }, + "server": { + "answer_id": "n6", + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n6", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "not_a_decider", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "0000001a-0000-4000-8000-00000000001a" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n6", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "not_a_decider", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "11111111-2222-4333-8444-555555555555", + "authority": "Member", + "request_id": "0000001a-0000-4000-8000-00000000001a" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E36A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AD6E6F745F615F64656369646572AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92431313131313131312D323232322D343333332D383434342D353535353535353535353535A9617574686F72697479A64D656D626572AA726571756573745F6964D92430303030303031612D303030302D343030302D383030302D303030303030303030303161" + }, + { + "name": "V10a_a_ruling_that_closed_the_fork_makes_a_retraction_fork_closed", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "card", + "body": null, + "server": null, + "response": null, + "card": { + "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" + ], + "decider_count": 1, + "status": "closed", + "ruling": "cultlib-gaps:resolution:question.slot-mark-consent.n1", + "ruled_from": [ + "n1" + ], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000001b-0000-4000-8000-00000000001b" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "fork_closed", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001b-0000-4000-8000-00000000001b" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n9", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "fork_closed", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001b-0000-4000-8000-00000000001b" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E39A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AB666F726B5F636C6F736564AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031622D303030302D343030302D383030302D303030303030303030303162" + }, + { + "name": "V11_retracting_the_counted_answer_makes_the_next_decider_answer_counted", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "Share it, the audience is settled." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:05:00Z", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + "server": { + "answer_id": "n3", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:05:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000001c-0000-4000-8000-00000000001c" + }, + "server": { + "answer_id": "n8", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001c-0000-4000-8000-00000000001c" + }, + "card": null, + "counted_answer_id": "n3" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n3", + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001c-0000-4000-8000-00000000001c" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E33AE616E7377657265725F636F756E7402A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031632D303030302D343030302D383030302D303030303030303030303163" + }, + { + "name": "V11_retracting_an_answer_that_was_not_counted_changes_nothing", + "card": { + "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": 2, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "Share it, the audience is settled." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:05:00Z", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + "server": { + "answer_id": "n3", + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "received_at": "2026-11-12T19:05:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n3", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000001d-0000-4000-8000-00000000001d" + }, + "server": { + "answer_id": "n8", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "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": "n3", + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001d-0000-4000-8000-00000000001d" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 2, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n3", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Revision", + "of": "n1", + "retracts": null, + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "yes", + "note": "Share it, the audience is settled.", + "answered_at": "2026-11-12T19:05:00Z", + "received_at": "2026-11-12T19:05:05Z", + "claim_in_future": false, + "answerer": "9f8e7d6c-5b4a-4392-8170-0f1e2d3c4b5a", + "authority": "Steward", + "request_id": "00000003-0000-4000-8000-000000000003" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n8", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Retraction", + "of": null, + "retracts": "n3", + "code": null, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": null, + "note": null, + "answered_at": "2026-11-12T19:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001d-0000-4000-8000-00000000001d" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7402A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E33A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A85265766973696F6EA26F66A26E31A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465D92253686172652069742C207468652061756469656E636520697320736574746C65642EAB616E7377657265645F6174B4323032362D31312D31325431393A30353A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30353A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92439663865376436632D356234612D343339322D383137302D306631653264336334623561A9617574686F72697479A753746577617264AA726571756573745F6964D92430303030303030332D303030302D343030302D383030302D303030303030303030303033DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E33A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031642D303030302D343030302D383030302D303030303030303030303164" + }, + { + "name": "after_every_answer_is_retracted_a_fresh_answer_is_Counted_again", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000001e-0000-4000-8000-00000000001e" + }, + "server": { + "answer_id": "n8", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001e-0000-4000-8000-00000000001e" + }, + "card": null, + "counted_answer_id": null + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "yes", + "note": "On reflection, share it." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:06:00Z", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:06:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + }, + "card": null, + "counted_answer_id": "n2" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n2", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001e-0000-4000-8000-00000000001e" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Counted", + "of": null, + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000002-0000-4000-8000-000000000002" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E32AE616E7377657265725F636F756E7401A7616E737765727393DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031652D303030302D343030302D383030302D303030303030303030303165DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A3796573A46E6F7465B84F6E207265666C656374696F6E2C2073686172652069742EAB616E7377657265645F6174B4323032362D31312D31325431393A30363A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30363A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030322D303030302D343030302D383030302D303030303030303030303032" + }, + { + "name": "R1_a_retraction_with_an_answer_is_a_stored_bad_retraction_and_a_retry_replays_it", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + }, + { + "name": "R1_a_retraction_with_no_target_is_a_stored_bad_retraction_and_a_retry_replays_it", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" + }, + { + "name": "R1_a_plain_answer_with_a_target_is_a_stored_bad_retraction_and_a_retry_replays_it", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + }, + { + "name": "a_replay_returns_the_counted_answer_id_current_at_the_retry_not_the_one_stored", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:09:00Z", + "request_id": "0000001f-0000-4000-8000-00000000001f" + }, + "server": { + "answer_id": "n8", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001f-0000-4000-8000-00000000001f" + }, + "card": null, + "counted_answer_id": null + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": null + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": null, + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "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:09:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000001f-0000-4000-8000-00000000001f" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964C0AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E38A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65AA52657472616374696F6EA26F66C0A87265747261637473A26E31A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30393A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303031662D303030302D343030302D383030302D303030303030303030303166" + }, + { + "name": "R1_retracted_true_with_an_answer_and_no_target_is_a_stored_bad_retraction", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": true, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + }, + { + "name": "R1_retracted_true_then_false_under_one_request_id_is_request_id_conflict", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + }, + { + "name": "R1_retracted_false_then_true_under_one_request_id_is_request_id_conflict", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": false, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": null + }, + "retracted": true, + "retracts": "n1", + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000a001-0000-4000-8000-00000000a001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "request_id_conflict", + "error": "request_id_conflict: this request_id was used for a different body" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": "n1", + "code": "bad_retraction", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "choice": "no", + "note": null, + "answered_at": "2026-11-12T19:02:00Z", + "received_at": "2026-11-12T19:09:05Z", + "claim_in_future": false, + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000a001-0000-4000-8000-00000000a001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473A26E31A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030613030312D303030302D343030302D383030302D303030303030303061303031" + }, + { + "name": "R1_a_target_less_retraction_with_retracted_flipped_is_body_invalid_before_any_request_id_lookup", + "card": { + "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" + ], + "decider_count": 1, + "status": "open", + "ruling": null, + "ruled_from": [], + "order": 3, + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48" + }, + "optionsHash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "steps": [ + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": { + "choice": "no", + "note": "Keep it local: a slot mark is the author's call, and sharing it is a separate act." + }, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "server": { + "answer_id": "n1", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:02:05Z" + }, + "response": { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": true, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "server": { + "answer_id": "n2", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.answer_result.v0", + "answer": { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "card": null, + "counted_answer_id": "n1" + } + }, + { + "kind": "answer", + "body": { + "schemaVersion": "gamecult.fork.answer_request.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "answer": null, + "retracted": false, + "retracts": null, + "answered_at": "2026-11-12T19:02:00Z", + "request_id": "0000b001-0000-4000-8000-00000000b001" + }, + "server": { + "answer_id": "n9", + "answerer": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "received_at": "2026-11-12T19:09:05Z" + }, + "response": { + "schemaVersion": "gamecult.fork.refusal.v0", + "code": "body_invalid", + "error": "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0" + } + } + ], + "record": { + "schemaVersion": "gamecult.fork.record.v0", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "options_hash": "7d95fc87d532420611fe415c1def27a3447617bb68235f46e3af130aea361c48", + "counted_answer_id": "n1", + "answerer_count": 1, + "answers": [ + { + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "00000001-0000-4000-8000-000000000001" + }, + { + "schemaVersion": "gamecult.desk_answer.v0", + "answer_id": "n2", + "fork_id": "gamecult-chat:question:slot-mark-consent", + "outcome": "Refused", + "of": null, + "retracts": null, + "code": "bad_retraction", + "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": "e0d1c2b3-a495-4687-8879-6a5b4c3d2e1f", + "authority": "Operator", + "request_id": "0000b001-0000-4000-8000-00000000b001" + } + ] + }, + "recordHex": "86AD736368656D6156657273696F6EB767616D6563756C742E666F726B2E7265636F72642E7630A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438B1636F756E7465645F616E737765725F6964A26E31AE616E7377657265725F636F756E7401A7616E737765727392DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E31A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A7436F756E746564A26F66C0A87265747261637473C0A4636F6465C0AC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365A26E6FA46E6F7465D9524B656570206974206C6F63616C3A206120736C6F74206D61726B2069732074686520617574686F7227732063616C6C2C20616E642073686172696E672069742069732061207365706172617465206163742EAB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30323A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030303030312D303030302D343030302D383030302D303030303030303030303031DE0010AD736368656D6156657273696F6EB767616D6563756C742E6465736B5F616E737765722E7630A9616E737765725F6964A26E32A7666F726B5F6964D92867616D6563756C742D636861743A7175657374696F6E3A736C6F742D6D61726B2D636F6E73656E74A76F7574636F6D65A752656675736564A26F66C0A87265747261637473C0A4636F6465AE6261645F72657472616374696F6EAC6F7074696F6E735F68617368D94037643935666338376435333234323036313166653431356331646566323761333434373631376262363832333566343665336166313330616561333631633438A663686F696365C0A46E6F7465C0AB616E7377657265645F6174B4323032362D31312D31325431393A30323A30305AAB72656365697665645F6174B4323032362D31312D31325431393A30393A30355AAF636C61696D5F696E5F667574757265C2A8616E737765726572D92465306431633262332D613439352D343638372D383837392D366135623463336432653166A9617574686F72697479A84F70657261746F72AA726571756573745F6964D92430303030623030312D303030302D343030302D383030302D303030303030303062303031" + } + ] +} diff --git a/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs b/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs new file mode 100644 index 0000000..7122a76 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/registration-page-harness.cjs @@ -0,0 +1,64 @@ +// Runs the sign-up page's real inline script against a simulated browser and prints what it did as JSON. +// Usage: node registration-page-harness.cjs +const fs = require("fs"); +const vm = require("vm"); +const { webcrypto } = require("crypto"); + +const html = fs.readFileSync(process.argv[2], "utf8"); +const scripts = [...html.matchAll(/"); + Assert.DoesNotContain("alert(1)", hostile); + Assert.DoesNotContain("Google is now linked", hostile); + } + + // ---- 6. google stays out of sign-in and member access ---- + + [Theory] + [InlineData("/auth/heimdall/google")] + [InlineData("/auth/heimdall/google?access=member")] + [InlineData("/auth/heimdall/Google")] + public async Task Google_is_refused_as_a_sign_in_provider_and_asks_Heimdall_for_nothing(string url) + { + using var host = new HeimdallHost(realSessions: true); + + var start = await host.StartAtAsync(url); + + Assert.Equal(HttpStatusCode.Redirect, start.Response.StatusCode); + Assert.Equal(ErrorRedirect, Location(start.Response)); + Assert.Empty(host.Heimdall.StartBodies); + } + + // ---- 7. nothing secret leaves the request ---- + + [Fact] + public async Task No_heimdall_token_or_pending_link_id_reaches_a_log_a_cookie_or_a_location() + { + using var host = new HeimdallHost(traceHttp: true, realSessions: true); + const string pendingCanary = "PENDING-CANARY-7f3a"; + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var pending = host.Heimdall.RegisterPendingLink(pendingCanary); + var c = await StepC(host, session, b.Next, pending); + var d = await StepD(host, session, c.Next, account); + Assert.Equal(Status + "?link=linked", Location(d.Response)); + + var tokens = new[] { b.Token, d.Token } + .SelectMany(token => new[] { token, token.Split('.')[2], token.Split('.')[1] }) + .ToArray(); + var logs = string.Join("\n", host.Logs.Lines); + Assert.Contains("Bearer", string.Join("\n", host.Heimdall.StartAuthorizations.Where(x => x is not null))); + foreach (var secret in tokens.Append(pendingCanary)) + { + Assert.DoesNotContain(secret, logs); + foreach (var step in new[] { start.Response, b.Response, c.Response, d.Response }) + { + Assert.DoesNotContain(secret, Location(step) ?? string.Empty); + } + } + + // Every attempt cookie Bifrost set along the way, unprotected: purpose, account and pending id only. + foreach (var cookie in new[] { start.Cookie, b.Next.Cookie, c.Next.Cookie }) + { + var payload = Unprotect(host, cookie!); + foreach (var token in tokens) + { + Assert.DoesNotContain(token, payload); + } + + Assert.DoesNotContain("eyJ", payload); + } + + Assert.DoesNotContain(pendingCanary, Unprotect(host, start.Cookie!)); + Assert.DoesNotContain(pendingCanary, Unprotect(host, b.Next.Cookie!)); + Assert.Contains(account, Unprotect(host, b.Next.Cookie!)); + } + + // ---- 8. old attempt cookies keep signing in ---- + + [Fact] + public async Task An_attempt_cookie_written_before_purposes_existed_still_completes_a_normal_sign_in() + { + using var host = new HeimdallHost(realSessions: true); + var nonce = "legacy-nonce-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var legacy = protector.Protect(JsonSerializer.Serialize(new Dictionary + { + ["Nonce"] = nonce, + ["Provider"] = "discord", + ["RequiresMemberAccess"] = false, + ["ReturnPath"] = "/App/Mill", + ["ExpiresAt"] = host.Clock.Now.AddMinutes(5) + })); + var account = HeimdallShapes.NewAccountId(); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], legacy, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + + var done = await host.CompleteAsync(attempt, host.CodeFor(attempt, account)); + + Assert.True(done.SignedIn); + Assert.Equal("/App/Mill", Location(done.Response)); + Assert.NotNull(await host.Account(account)); + } + + // ---- 9. attempts are single use ---- + + [Fact] + public async Task Replaying_a_consumed_link_start_attempt_does_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var code = host.CodeFor(start, account); + var fields = new Dictionary { ["attempt"] = start.Nonce, ["code"] = code }; + var first = await host.PostCompleteAsync(fields, Jar(session, start)); + Assert.Contains("google", Location(first)); + var starts = host.Heimdall.StartBodies.Count; + + var replay = await host.PostCompleteAsync(fields, Jar(session, start)); + + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(Status + "?link=error", Location(replay)); + } + + [Fact] + public async Task Replaying_a_consumed_confirm_attempt_confirms_nothing_twice() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var code = host.CodeFor(journey.C.Next, journey.Account); + var fields = new Dictionary { ["attempt"] = journey.C.Next.Nonce, ["code"] = code }; + var first = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + Assert.Equal(Status + "?link=linked", Location(first)); + + var replay = await host.PostCompleteAsync(fields, Jar(journey.Session, journey.C.Next)); + + Assert.Single(host.Heimdall.ConfirmBodies); + Assert.Equal(Status + "?link=error", Location(replay)); + } + + [Fact] + public async Task Replaying_a_consumed_await_attempt_links_nothing() + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + var again = await StepC(host, journey.Session, journey.B.Next, journey.PendingLinkId); + + // The replay can only begin another fresh sign-in; the pending id is spent at Heimdall by the first confirm. + var d = await StepD(host, journey.Session, again.Next, journey.Account); + var replayed = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Equal(Status + "?link=expired", Location(replayed.Response)); + } + + // ---- 10. pins for guards the first pass left unproven ---- + + [Fact] + public async Task A_link_continues_in_its_own_slot_and_leaves_a_parallel_sign_in_attempt_alive() + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var parallel = await host.StartAsync(); + var parallelCookie = $"{parallel.CookieName}={parallel.Cookie}"; + var start = await host.StartLinkAsync(session, extraCookies: parallelCookie); + // The parallel attempt holds slot 0, so the link lives in the other slot. + Assert.Equal(HeimdallAttemptCookie.SlotNames[0], parallel.CookieName); + Assert.Equal(HeimdallAttemptCookie.SlotNames[1], start.CookieName); + + var b = await StepB(host, session, start, account); + var awaiting = new Step(b.Response, b.Next, b.Token); + var pending = host.Heimdall.RegisterPendingLink(); + var c = await StepC(host, session, awaiting.Next, pending); + var d = await StepD(host, session, c.Next, account); + + // Every continuation wrote the link's own slot and none touched the parallel attempt's slot. + foreach (var response in new[] { b.Response, c.Response }) + { + Assert.NotNull(HeimdallHost.SetCookieHeader(response, start.CookieName!)); + Assert.Null(HeimdallHost.SetCookieHeader(response, parallel.CookieName!)); + } + + Assert.Equal(start.CookieName, b.Next.CookieName); + Assert.Equal(start.CookieName, c.Next.CookieName); + Assert.Equal(Status + "?link=linked", Location(d.Response)); + Assert.Null(HeimdallHost.SetCookieHeader(d.Response, parallel.CookieName!)); + + var signIn = await host.CompleteWithHeaderAsync( + parallel.Nonce, host.CodeFor(parallel, HeimdallShapes.NewAccountId()), parallelCookie); + Assert.True(signIn.SignedIn); + } + + [Theory] + [InlineData("9")] + [InlineData("-1")] + [InlineData("4")] + public async Task An_attempt_cookie_with_an_undefined_purpose_is_no_attempt_and_asks_Heimdall_for_nothing(string purpose) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var nonce = "crafted-" + Guid.NewGuid().ToString("N"); + var protector = host.Services.GetRequiredService().CreateProtector("Bifrost.Heimdall.Attempt.v1"); + var crafted = protector.Protect( + $$"""{"Nonce":"{{nonce}}","Provider":"discord","RequiresMemberAccess":false,"ReturnPath":"/App/Mill","ExpiresAt":"{{host.Clock.Now.AddMinutes(5):O}}","Purpose":{{purpose}},"ExpectedAccountId":"{{account}}","PendingLinkId":"p"}"""); + var attempt = new StartedAttempt( + nonce, HeimdallAttemptCookie.SlotNames[0], crafted, $"https://localhost/auth/heimdall/complete?attempt={nonce}", null!); + var starts = host.Heimdall.StartBodies.Count; + var redeems = host.Heimdall.RedeemCalls; + + var response = await host.PostCompleteAsync( + new Dictionary + { + ["attempt"] = nonce, ["code"] = host.CodeFor(attempt, account), ["pendingLinkId"] = "p" + }, + Jar(session, attempt)); + + Assert.Equal(ErrorRedirect, Location(response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.ConfirmBodies); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task An_await_step_without_a_pending_link_id_is_refused_before_Heimdall_hears_of_it(string pendingLinkId) + { + using var host = new HeimdallHost(realSessions: true); + var account = HeimdallShapes.NewAccountId(); + var session = await host.SignInSessionAsync(account); + var start = await host.StartLinkAsync(session); + var b = await StepB(host, session, start, account); + var starts = host.Heimdall.StartBodies.Count; + var redeems = host.Heimdall.RedeemCalls; + + var c = await StepC(host, session, b.Next, pendingLinkId); + + Assert.Equal(Status + "?link=error", Location(c.Response)); + Assert.Equal(starts, host.Heimdall.StartBodies.Count); + Assert.Equal(redeems, host.Heimdall.RedeemCalls); + Assert.Empty(host.Heimdall.ConfirmBodies); + AssertAttemptCleared(c.Response, b.Next); + } + + [Theory] + [InlineData("")] + [InlineData("{}")] + [InlineData("""{"status":"pending"}""")] + [InlineData("""{"status":"Linked"}""")] + [InlineData("not json")] + public async Task A_201_confirm_that_does_not_say_linked_is_not_shown_as_linked(string body) + { + using var host = new HeimdallHost(realSessions: true); + var journey = await ThroughC(host); + host.Heimdall.ConfirmStatus = HttpStatusCode.Created; + host.Heimdall.ConfirmBody = body; + + var d = await StepD(host, journey.Session, journey.C.Next, journey.Account); + + Assert.Equal(Status + "?link=error", Location(d.Response)); + Assert.Single(host.Heimdall.ConfirmBodies); + AssertAttemptCleared(d.Response, journey.C.Next); + } +} diff --git a/tests/Bifrost.Web.Tests/HostTrustTests.cs b/tests/Bifrost.Web.Tests/HostTrustTests.cs index 54c0128..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 { @@ -362,7 +394,9 @@ public sealed class HostTrustTests : IClassFixture MintPermits = mintPermits, MintWindow = mintWindow ?? TimeSpan.FromHours(1), MaxTrackedKeys = maxTrackedKeys - })).Validate(); + }), + Options.Create(new DeskOptions()), + Options.Create(new RegistrationOptions())).Validate(); } private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment 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 new file mode 100644 index 0000000..97f0c99 --- /dev/null +++ b/tests/Bifrost.Web.Tests/MembershipSuspensionTests.cs @@ -0,0 +1,196 @@ +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; + +namespace Bifrost.Web.Tests; + +/// A suspension is an admin's decision: only the admin path sets or lifts it, with a receipt, and the members page's Approve cannot lift it. +public sealed class MembershipSuspensionTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public MembershipSuspensionTests(TestWebApplicationFactory factory) => _factory = factory; + + private sealed record Seeded(Guid AdminId, Guid MemberId, Guid OrdinaryId, Guid OtherAdminId); + + private async Task SeedAsync(MembershipStatus memberStatus = MembershipStatus.Active, bool approved = true) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + UserAccount Account(string name, MembershipStatus status, bool admin, bool wasApproved) => new() + { + DisplayName = name, + BifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20], + NormalizedBifrostIdentity = $"{name}-{Guid.NewGuid():N}"[..20], + Membership = new Bifrost.Web.Domain.Membership + { + Status = status, + IsPlatformAdmin = admin, + ApprovedAtUtc = wasApproved ? DateTimeOffset.UtcNow : null + } + }; + + var admin = Account("admin", MembershipStatus.Active, true, true); + var otherAdmin = Account("admin2", MembershipStatus.Active, true, true); + var member = Account("member", memberStatus, false, approved); + var ordinary = Account("ordinary", MembershipStatus.Active, false, true); + db.UserAccounts.AddRange(admin, otherAdmin, member, ordinary); + await db.SaveChangesAsync(); + return new Seeded(admin.Id, member.Id, ordinary.Id, otherAdmin.Id); + } + + private async Task InScopeAsync(Func> body) + { + using var scope = _factory.Services.CreateScope(); + return await body(scope.ServiceProvider, scope.ServiceProvider.GetRequiredService()); + } + + private Task ActorAsync(Guid id) => InScopeAsync(async (_, db) => + new CurrentBifrostActor(true, await db.UserAccounts.Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments).SingleAsync(x => x.Id == id))); + + private Task 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()); + + private Task SuspendAsync(Guid by, Guid target, string? reason) => InScopeAsync(async (sp, _) => + await sp.GetRequiredService().SuspendAsync(await ActorAsync(by), target, reason, CancellationToken.None)); + + private Task UnsuspendAsync(Guid by, Guid target, string? reason) => InScopeAsync(async (sp, _) => + await sp.GetRequiredService().UnsuspendAsync(await ActorAsync(by), target, reason, CancellationToken.None)); + + [Fact] + public async Task Only_a_platform_admin_can_suspend_and_the_receipt_names_who_and_why() + { + var seed = await SeedAsync(); + + Assert.Equal(SuspensionOutcome.NotAdmin, await SuspendAsync(seed.OrdinaryId, seed.MemberId, "because")); + Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId)); + Assert.Equal(SuspensionOutcome.NoReason, await SuspendAsync(seed.AdminId, seed.MemberId, " ")); + Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId)); + + Assert.Equal(SuspensionOutcome.Done, await SuspendAsync(seed.AdminId, seed.MemberId, "spam in chat")); + Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId)); + 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] + public async Task An_admin_cannot_suspend_themselves_or_another_platform_admin() + { + var seed = await SeedAsync(); + + Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.AdminId, "oops")); + Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.OtherAdminId, "coup")); + Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.AdminId)); + Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.OtherAdminId)); + } + + [Fact] + public async Task A_suspension_reason_is_on_the_membership_row_not_the_audit_trail_or_the_member_dashboard_snapshot() + { + var seed = await SeedAsync(); + await SuspendAsync(seed.AdminId, seed.MemberId, "private-reason-canary"); + + var snapshot = await InScopeAsync(async (sp, _) => await sp.GetRequiredService().GetAsync(seed.OrdinaryId, CancellationToken.None)); + + Assert.DoesNotContain(snapshot.RecentActivity, 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] + public async Task A_role_granted_admin_is_protected_like_a_flagged_one() + { + var seed = await SeedAsync(); + await InScopeAsync(async (_, db) => + { + var membership = await db.Memberships.SingleAsync(x => x.UserAccountId == seed.MemberId); + db.RoleAssignments.Add(new RoleAssignment { MembershipId = membership.Id, Role = MemberRole.PlatformAdmin }); + await db.SaveChangesAsync(); + return 0; + }); + + Assert.Equal(SuspensionOutcome.ProtectedAccount, await SuspendAsync(seed.AdminId, seed.MemberId, "role admin")); + } + + [Theory] + [InlineData(true, MembershipStatus.Active)] + [InlineData(false, MembershipStatus.Authenticated)] + public async Task Unsuspend_works_only_by_the_admin_path_and_with_a_reason(bool approved, MembershipStatus restored) + { + var seed = await SeedAsync(MembershipStatus.Suspended, approved); + + Assert.Equal(SuspensionOutcome.NotAdmin, await UnsuspendAsync(seed.OrdinaryId, seed.MemberId, "please")); + Assert.Equal(SuspensionOutcome.NoReason, await UnsuspendAsync(seed.AdminId, seed.MemberId, null)); + Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId)); + + await InScopeAsync(async (_, db) => + { + (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.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")); + } + + [Fact] + public async Task The_members_page_Approve_cannot_lift_a_suspension() + { + var seed = await SeedAsync(MembershipStatus.Suspended); + + var result = await InScopeAsync(async (sp, db) => + { + var page = new IndexModel( + db, new FixedActor(await ActorAsync(seed.AdminId)), sp.GetRequiredService(), + sp.GetRequiredService(), TimeProvider.System); + return await page.OnPostApproveAsync(seed.MemberId, CancellationToken.None); + }); + + Assert.IsType(result); + Assert.Equal(MembershipStatus.Suspended, await StatusAsync(seed.MemberId)); + } + + [Fact] + public async Task The_members_page_suspend_handler_forbids_a_non_admin_and_changes_nothing() + { + var seed = await SeedAsync(); + + var result = await InScopeAsync(async (sp, db) => + { + var page = new IndexModel( + db, new FixedActor(await ActorAsync(seed.OrdinaryId)), sp.GetRequiredService(), + sp.GetRequiredService(), TimeProvider.System); + return await page.OnPostSuspendAsync(seed.MemberId, "nope", CancellationToken.None); + }); + + Assert.IsType(result); + Assert.Equal(MembershipStatus.Active, await StatusAsync(seed.MemberId)); + } + + private sealed class FixedActor(CurrentBifrostActor actor) : ICurrentBifrostActorAccessor + { + public Task GetAsync(CancellationToken cancellationToken) => Task.FromResult(actor); + } +} diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 3d4b738..6d902cb 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -24,6 +24,10 @@ public sealed class MigrationDiscoveryTests "20261022163159_AddOpenIddict", "20261109200000_AddSealedBallotMotions", "20261109210000_AddMindKeys", + "20261111090000_AddConsentReceipts", + "20261111100000_ConsentReceiptsOneLivePerVersion", + "20261111110000_AddMembershipSuspensionReason", + "20261111120000_ClearLegacyConsentReceipts", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -53,7 +57,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..5deb1a8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/NoThirdPartyAssetTests.cs @@ -0,0 +1,132 @@ +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace Bifrost.Web.Tests; + +// The site serves every script and stylesheet itself: a page that pulls code or styling from another origin hands +// that origin the visitor's session. Fails on any cross-origin ", true)] + [InlineData("", true)] + [InlineData("html.Append(\"", false)] + [InlineData("
repo", false)] + public void The_source_pattern_catches_cross_origin_assets_and_allows_same_origin_and_fonts(string markup, bool flagged) => + Assert.Equal(flagged, CrossOriginSource().IsMatch(markup)); + + [Theory] + [InlineData("
", true)] + [InlineData("
", true)] + [InlineData("token=ab-hx-9f3", false)] + public void Htmx_is_matched_only_as_an_attribute(string markup, bool flagged) => + Assert.Equal(flagged, HtmxAttribute().IsMatch(markup)); + + [Fact] + public async Task Real_pages_load_no_third_party_asset() + { + var collection = "c" + Guid.NewGuid().ToString("N"); + var identity = $"assets-{Guid.NewGuid():N}"; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow }); + db.UserAccounts.Add(new UserAccount { BifrostIdentity = identity, NormalizedBifrostIdentity = identity, DisplayName = identity }); + await db.SaveChangesAsync(); + } + + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, BaseAddress = new Uri("https://localhost") }); + var pages = new (string Path, string? Identity, bool Anonymous)[] + { + ("/", null, true), + ($"/mill/{collection}/alpha", null, true), + ($"/mill/{collection}/alpha", null, false), + ("/App", null, false), + ("/auth/sign-in", null, true), + ("/auth/bifrost/register", null, true), + ("/auth/bifrost/consent", identity, false), + }; + + foreach (var (path, who, anonymous) in pages) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + else if (who is not null) + { + request.Headers.Add("X-Test-Bifrost-Identity", who); + } + + using var response = await client.SendAsync(request); + Assert.True(response.StatusCode == HttpStatusCode.OK, $"{path} returned {response.StatusCode}"); + var html = await response.Content.ReadAsStringAsync(); + Assert.False(string.IsNullOrWhiteSpace(html), path); + Assert.DoesNotContain("unpkg.com", html, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("htmx", html, StringComparison.OrdinalIgnoreCase); + Assert.False(HtmxAttribute().IsMatch(html), $"{path} carries an hx- attribute"); + Assert.False(CrossOriginAsset().IsMatch(html), $"{path} loads a cross-origin asset"); + } + } +} diff --git a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs index 08e8595..2c3b9a5 100644 --- a/tests/Bifrost.Web.Tests/OidcIssuerTests.cs +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Bifrost.Web.Domain; using Bifrost.Web.Features.Shared; using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +20,7 @@ namespace Bifrost.Web.Tests; public sealed class OidcIssuerTests : IClassFixture { private static readonly Uri Origin = new("https://localhost"); - private readonly OidcWebApplicationFactory _factory; + private OidcWebApplicationFactory _factory; public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory; @@ -106,20 +107,74 @@ public sealed class OidcIssuerTests : IClassFixture Assert.Equal("Second Name", second.Claim("name")); } - [Theory] - [InlineData(MembershipStatus.Authenticated)] - [InlineData(MembershipStatus.PendingApproval)] - [InlineData(MembershipStatus.Suspended)] - public async Task A_member_who_is_not_active_is_refused_with_access_denied_and_no_code(MembershipStatus status) + [Fact] + public async Task A_suspended_account_is_refused_with_access_denied_and_no_code() { - var (client, _) = await SignedInAsync(status, "Not Active"); + var (client, _) = await SignedInAsync(MembershipStatus.Suspended, "Suspended One"); 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.NotActiveDescription, redirect["error_description"]); + Assert.Equal(Bifrost.Web.Features.Oidc.OidcEndpoints.SuspendedDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + public async Task A_non_member_gets_a_chat_only_token_and_writes_no_membership_or_vote(MembershipStatus status) + { + var (client, identity) = await SignedInAsync(status, "Chat Only"); + var accountId = _factory.AccountId(identity); + var before = await _factory.RowCountsAsync(); + + var tokens = await CompleteFlowAsync(client); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, tokens.Request.Nonce); + Assert.Equal(accountId.ToString("D"), verified.Claim("sub")); + Assert.Equal(identity, verified.Claim("preferred_username")); + + // The issuer wrote nothing about membership or votes, and the status is exactly what it was. + Assert.Equal(before, await _factory.RowCountsAsync()); + Assert.Equal(status, await _factory.MembershipStatusAsync(identity)); + Assert.Equal(0, (await _factory.RowCountsAsync()).Votes); + + // Neither the session that signed in to chat nor its token opens a member or governance surface. + foreach (var path in new[] { "/eve/governance/surface", "/api/sealed-motions", "/App" }) + { + using var viaSession = await client.GetAsync(path); + Assert.NotEqual(HttpStatusCode.OK, viaSession.StatusCode); + + using var viaToken = new HttpRequestMessage(HttpMethod.Get, path); + viaToken.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var bearer = await NewClient().SendAsync(viaToken); + Assert.NotEqual(HttpStatusCode.OK, bearer.StatusCode); + } + + using var desk = new HttpRequestMessage(HttpMethod.Get, "/api/desk/forks"); + desk.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var deskResponse = await NewClient().SendAsync(desk); + Assert.NotEqual(HttpStatusCode.OK, deskResponse.StatusCode); + + // Last, because userinfo answers 500 on a .NET 10 runtime (the six known failures), and the rest must still run there. + using var userinfo = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode); + var info = JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["name", "preferred_username", "sub"], info.EnumerateObject().Select(x => x.Name).Order().ToArray()); + } + + [Fact] + public async Task A_chat_tier_account_with_a_reserved_identity_is_still_refused() + { + using var factory = new OidcFixedIdentityFactory("eureka", MembershipStatus.Authenticated); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); + var request = NewRequest(); + + var redirect = AssertRedirectToClient(await DriveAsync(client, request), request.State); + Assert.Equal("access_denied", redirect["error"]); Assert.False(redirect.ContainsKey("code")); } @@ -139,6 +194,290 @@ public sealed class OidcIssuerTests : IClassFixture Assert.False(redirect.ContainsKey("code")); } + [Theory] + [InlineData("meta")] // an operator's or a Mind's Matrix localpart + [InlineData("eureka")] + [InlineData("admin")] + [InlineData("system")] // a system name + [InlineData("Heimdall")] // the row's casing does not matter + public async Task An_active_member_whose_identity_is_a_reserved_localpart_gets_no_code(string reserved) + { + using var factory = new OidcFixedIdentityFactory(reserved); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"cookie-{Guid.NewGuid():N}"); // any session: the actor is the fixed account + 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.ReservedIdentityDescription, redirect["error_description"]); + Assert.False(redirect.ContainsKey("code")); + } + + [Theory] + [InlineData("meta2")] // look-alikes are ordinary names + [InlineData("admins")] + [InlineData("kiko-fan")] + public async Task A_name_that_only_resembles_a_reserved_localpart_is_given_a_code(string ordinary) + { + 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); + + var redirect = AssertRedirectToClient(final, request.State); + Assert.True(redirect.ContainsKey("code")); + Assert.False(redirect.ContainsKey("error")); + } + + private static IReadOnlyDictionary Settings(params (string Key, string Value)[] pairs) => + pairs.ToDictionary(p => $"Bifrost:Oidc:{p.Key}", p => (string?)p.Value); + + [Fact] + public async Task A_flood_from_one_address_is_refused_with_429_and_a_retry_after() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "5"))); + using var client = factory.CreateClient(ClientOptions()); + + var statuses = new List(); + HttpResponseMessage? last = null; + for (var i = 0; i < 8; i++) + { + last?.Dispose(); + last = await client.GetAsync(NewRequest().Path); + statuses.Add(last.StatusCode); + } + + Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(5)); + Assert.All(statuses.Skip(5), status => Assert.Equal(HttpStatusCode.TooManyRequests, status)); + Assert.True(int.Parse(last!.Headers.GetValues("Retry-After").Single()) >= 1); + using var tokenFlood = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary())); + Assert.Equal(HttpStatusCode.TooManyRequests, tokenFlood.StatusCode); // one budget over all three endpoints + using var userinfoFlood = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.TooManyRequests, userinfoFlood.StatusCode); + last.Dispose(); + } + + [Fact] + public async Task A_flood_naming_the_real_client_does_not_spend_anyone_elses_permits_and_a_trailing_slash_is_no_way_around() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "4"))); + using var client = factory.CreateClient(ClientOptions()); + + var statuses = new List(); + foreach (var path in new[] { NewRequest().Path, NewRequest().Path, "/connect/authorize/?client_id=x", "/connect/token/", "/connect/userinfo/" }) + { + using var response = await client.GetAsync(path); + statuses.Add(response.StatusCode); + } + + Assert.Equal(HttpStatusCode.TooManyRequests, statuses[4]); // the slashed forms spend the same budget + Assert.DoesNotContain(HttpStatusCode.TooManyRequests, statuses.Take(4)); + } + + [Theory] + [InlineData("/connect/authorize")] + [InlineData("/connect/authorize/")] + [InlineData("/connect/token/")] + [InlineData("/connect/userinfo/")] + [InlineData("/CONNECT/Userinfo")] + public async Task The_brake_holds_for_every_spelling_of_an_endpoint(string path) + { + var marker = Path.Combine(Path.GetTempPath(), $"oidc-brake-{Guid.NewGuid():N}"); + File.WriteAllText(marker, "braked"); + try + { + using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker))); + using var client = factory.CreateClient(ClientOptions()); + using var response = await client.GetAsync(path == "/connect/authorize" || path == "/connect/authorize/" ? path + "?client_id=x" : path); + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + finally + { + File.Delete(marker); + } + } + + [Fact] + public async Task A_guard_that_cannot_decide_answers_503_and_never_passes_the_request_on() + { + var reached = false; + var options = new Bifrost.Web.Configuration.OidcIssuerOptions(); + var middleware = new Bifrost.Web.Features.Oidc.OidcGuardMiddleware( + _ => { reached = true; return Task.CompletedTask; }, options, + new Bifrost.Web.Features.Oidc.OidcRateLimit(options, TimeProvider.System), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + var http = new DefaultHttpContext(); + http.Request.Path = "/connect/authorize"; + http.Request.QueryString = new QueryString("?client_id=x"); + http.Response.Body = new MemoryStream(); + + await middleware.InvokeAsync(http, System.Reflection.DispatchProxy.Create()); + + Assert.False(reached); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, http.Response.StatusCode); + } + + /// Every call fails: the row count the guard asks of the token table cannot be answered. + public class ThrowingProxy : System.Reflection.DispatchProxy + { + protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) => throw new InvalidOperationException("the token table is down"); + } + + [Fact] + public async Task A_resumed_authorize_request_is_not_refused_by_the_row_cap_but_a_fresh_one_is() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3"))); + _factory = factory; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Resumer"); + using var first = await client.GetAsync(NewRequest().Path); // parks one row, answers with a redirect carrying the request_uri + var resume = first.Headers.Location!.ToString(); + Assert.Contains("request_uri", resume); + + using var anonymous = factory.CreateClient(ClientOptions()); + for (var i = 0; i < 2; i++) + { + using var filler = await anonymous.GetAsync(NewRequest().Path); + } + + using var fresh = await anonymous.GetAsync(NewRequest().Path); + Assert.Equal(HttpStatusCode.ServiceUnavailable, fresh.StatusCode); + using var resumed = await client.GetAsync(resume); + Assert.NotEqual(HttpStatusCode.ServiceUnavailable, resumed.StatusCode); + } + + [Fact] + public async Task A_mas_flow_fits_the_default_budgets_and_the_window_reopens() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:PerAddressPermits", "30"))); + _factory = factory; + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Within Budget"); + + var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + Assert.Equal(identity, verified.Claim("preferred_username")); + + for (var i = 0; i < 40; i++) + { + using var burst = await client.GetAsync("/connect/userinfo"); + } + + using var refused = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.TooManyRequests, refused.StatusCode); + factory.Clock.Advance(TimeSpan.FromMinutes(1)); + using var reopened = await client.GetAsync("/connect/userinfo"); + Assert.NotEqual(HttpStatusCode.TooManyRequests, reopened.StatusCode); + } + + [Fact] + public async Task The_brake_marker_stops_the_issuer_and_releasing_it_needs_no_restart() + { + var marker = Path.Combine(Path.GetTempPath(), $"bifrost-oidc-brake-{Guid.NewGuid():N}"); + using var factory = new OidcSettingsFactory(Settings(("BrakePath", marker))); + _factory = factory; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Braked"); + try + { + Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released: the flow passes"); + + await File.WriteAllTextAsync(marker, "held"); + foreach (var path in new[] { NewRequest().Path, "/connect/userinfo" }) + { + using var held = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.ServiceUnavailable, held.StatusCode); + Assert.True(held.Headers.Contains("Retry-After")); + } + + using var token = await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary())); + Assert.Equal(HttpStatusCode.ServiceUnavailable, token.StatusCode); + using var discovery = await client.GetAsync("/.well-known/openid-configuration"); + Assert.Equal(HttpStatusCode.OK, discovery.StatusCode); // the brake stops issuing, not the published keys + + File.Delete(marker); + Assert.True((await CompleteFlowAsync(client)).IdToken.Length > 0, "released again, same process"); + } + finally + { + File.Delete(marker); + } + } + + [Fact] + public async Task Anonymous_authorize_requests_cannot_park_more_than_the_row_cap_and_the_prune_gives_the_room_back() + { + using var factory = new OidcSettingsFactory(Settings(("Limits:MaxTokenRows", "3"))); + _factory = factory; + using var client = factory.CreateClient(ClientOptions()); + + var parked = new List(); + for (var i = 0; i < 5; i++) + { + using var response = await client.GetAsync(NewRequest().Path); + parked.Add(response.StatusCode); + } + + Assert.Equal(3, await factory.TokenRowsAsync()); + Assert.All(parked.Skip(3), status => Assert.Equal(HttpStatusCode.ServiceUnavailable, status)); + Assert.All(parked.Take(3), status => Assert.NotEqual(HttpStatusCode.ServiceUnavailable, status)); + + factory.Clock.Advance(TimeSpan.FromMinutes(11)); // past the ten-minute parked lifetime + await factory.PruneAsync(); + Assert.Equal(0, await factory.TokenRowsAsync()); + using var again = await client.GetAsync(NewRequest().Path); + Assert.NotEqual(HttpStatusCode.ServiceUnavailable, again.StatusCode); + } + + [Fact] + public async Task The_prune_removes_expired_rows_and_leaves_a_live_token_alone() + { + using var factory = new OidcSettingsFactory(Settings()); + _factory = factory; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Pruned"); + var tokens = await CompleteFlowAsync(client); // a parked request, a code and an access token existed along the way + using (var anonymous = factory.CreateClient(ClientOptions())) + { + using var parked = await anonymous.GetAsync(NewRequest().Path); // a parked request that will expire + } + + var before = await factory.TokenRowsAsync(); + await factory.PruneAsync(); + Assert.Equal(before, await factory.TokenRowsAsync()); // nothing has expired yet: nothing goes + + factory.Clock.Advance(TimeSpan.FromMinutes(11)); // parked requests and codes are dead, the access token (an hour) is not + await factory.PruneAsync(); + var after = await factory.TokenRowsAsync(); + Assert.True(after < before, "expired rows went"); + Assert.Contains("urn:ietf:params:oauth:token-type:access_token", await factory.TokenTypesAsync()); // the live one, not merely some row + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var response = await factory.CreateClient(ClientOptions()).SendAsync(userinfo); + Assert.NotEqual(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task The_prune_takes_a_revoked_authorization_with_its_tokens_and_touches_no_live_authorization() + { + using var factory = new OidcSettingsFactory(Settings()); + _factory = factory; + var (revoked, _) = await SignedInAsync(MembershipStatus.Active, "Revoked"); + await CompleteFlowAsync(revoked); + var (live, _) = await SignedInAsync(MembershipStatus.Active, "Live"); + await CompleteFlowAsync(live); + Assert.Equal(2, await factory.AuthorizationRowsAsync()); + Assert.Equal(6, await factory.TokensOfAuthorizationsAsync()); // a spent code, an access token and an id token each + await factory.RevokeFirstAuthorizationAsync(); + + factory.Clock.Advance(TimeSpan.FromMinutes(6)); // past the grace for a young row; the access tokens (an hour) are still alive + await factory.PruneAsync(); + + Assert.Equal(1, await factory.AuthorizationRowsAsync()); + Assert.Equal(2, await factory.TokenRowsAsync()); // no orphan stays either: the revoked one's tokens went with it; the live one's access and id tokens stayed, spent codes go + } + [Theory] [InlineData("unknown-client", OidcTestKit.RedirectUri, true)] [InlineData(OidcTestKit.ClientId, "https://evil.example.test/callback", true)] @@ -335,11 +674,9 @@ public sealed class OidcIssuerTests : IClassFixture } [Theory] - [InlineData(MembershipStatus.Authenticated)] - [InlineData(MembershipStatus.PendingApproval)] [InlineData(MembershipStatus.Suspended)] [InlineData(null)] - public async Task A_member_no_longer_active_after_authorize_gets_no_tokens(MembershipStatus? status) + public async Task An_account_suspended_or_gone_after_authorize_gets_no_tokens(MembershipStatus? status) { var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); var request = NewRequest(); @@ -356,11 +693,9 @@ public sealed class OidcIssuerTests : IClassFixture } [Theory] - [InlineData(MembershipStatus.Authenticated)] - [InlineData(MembershipStatus.PendingApproval)] [InlineData(MembershipStatus.Suspended)] [InlineData(null)] - public async Task Userinfo_refuses_an_access_token_whose_member_is_no_longer_active(MembershipStatus? status) + public async Task Userinfo_refuses_an_access_token_whose_account_is_suspended_or_gone(MembershipStatus? status) { var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Gone"); var tokens = await CompleteFlowAsync(client); @@ -491,6 +826,164 @@ public sealed class OidcIssuerTests : IClassFixture Assert.Equal(OidcForeignIssuerFactory.PublicBaseUrl, discovery.GetProperty("issuer").GetString()); } + // ---- chat needs a live receipt for the notice version in force; nothing else does ---- + + private const string ConsentPath = "/auth/bifrost/consent"; + + /// A form post as the consent page's own buttons send it: the site's own Origin, no antiforgery token. + private async Task ConsentPostAsync(HttpClient client, string action, string? returnUrl = null) + { + var fields = new Dictionary { ["version"] = Bifrost.Web.Features.Registration.TrainingNotice.Version.ToString() }; + if (returnUrl is not null) + { + fields["returnUrl"] = returnUrl; + } + + using var message = new HttpRequestMessage(HttpMethod.Post, $"{ConsentPath}/{action}") { Content = new FormUrlEncodedContent(fields) }; + message.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority)); + return await client.SendAsync(message); + } + + [Theory] + [InlineData(ReceiptState.None)] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + public async Task Chat_authorize_without_a_live_current_receipt_sends_the_member_to_the_consent_page_with_the_request_to_resume(ReceiptState receipt) + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "No Agreement", receipt); + var request = NewRequest(); + + var final = await DriveAsync(client, request); + + Assert.Equal(HttpStatusCode.Redirect, final.StatusCode); + var location = final.Headers.Location!.OriginalString; + Assert.DoesNotContain("code=", location); + Assert.DoesNotContain("error=", location); + Assert.StartsWith(ConsentPath + "?returnUrl=", location); + var resume = ReturnUrl(final); + Assert.StartsWith("/connect/authorize?", resume); + Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); // short enough to survive as a local return path + + using var page = await client.GetAsync(location); + var html = await page.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Contains("action=\"/auth/bifrost/consent/accept\"", html); + Assert.Contains("to speak in a recorded room", html); + } + + [Fact] + public async Task Agreeing_on_the_consent_page_resumes_the_refused_request_and_chat_works() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Authenticated, "Late Agreer", ReceiptState.None); + var request = NewRequest(); + var refused = await DriveAsync(client, request); + var resume = ReturnUrl(refused); + + using var agreed = await ConsentPostAsync(client, "accept", resume); + Assert.Equal(HttpStatusCode.SeeOther, agreed.StatusCode); + Assert.Equal(resume, agreed.Headers.Location!.OriginalString); + + using var resumed = await client.GetAsync(resume); + var code = AssertRedirectToClient(resumed, request.State)["code"]; + Assert.NotNull(code); + var tokens = await ExchangeAsync(client, request, code!); + using var userinfo = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.OK, userinfo.StatusCode); + Assert.Equal(identity, JsonDocument.Parse(await userinfo.Content.ReadAsStringAsync()).RootElement.GetProperty("preferred_username").GetString()); + } + + [Theory] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + public async Task A_code_and_a_token_issued_before_the_agreement_went_stop_working(ReceiptState gone) + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Soon Silent"); + var pendingRequest = NewRequest(); + var pendingCode = await CodeAsync(client, pendingRequest); + var tokens = await CompleteFlowAsync(client); + using (var before = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + } + + await _factory.SetReceiptAsync(identity, gone); + + using var code = await TokenAsync(client, pendingRequest, pendingCode); + var codeBody = await code.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, code.StatusCode); + Assert.Contains("invalid_grant", codeBody); + Assert.DoesNotContain("access_token", codeBody); + + using var after = await UserInfoAsync(tokens.AccessToken); + var afterBody = await after.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode); + Assert.Contains("insufficient_access", string.Join(" ", after.Headers.WwwAuthenticate.Select(x => x.ToString()))); + Assert.DoesNotContain(identity, afterBody); + Assert.DoesNotContain("preferred_username", afterBody); + } + + [Fact] + public async Task Withdrawing_on_the_consent_page_ends_chat_and_agreeing_again_restores_it() + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Changes Mind"); + var tokens = await CompleteFlowAsync(client); + + using (var withdrawn = await ConsentPostAsync(client, "withdraw")) + { + Assert.Equal(HttpStatusCode.SeeOther, withdrawn.StatusCode); + } + + using (var silenced = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.Forbidden, silenced.StatusCode); + } + + var request = NewRequest(); + Assert.StartsWith(ConsentPath, (await DriveAsync(client, request)).Headers.Location!.OriginalString); + + using (var again = await ConsentPostAsync(client, "accept")) + { + Assert.Equal(HttpStatusCode.SeeOther, again.StatusCode); + } + + var fresh = await CompleteFlowAsync(client); + using var restored = await UserInfoAsync(fresh.AccessToken); + Assert.Equal(HttpStatusCode.OK, restored.StatusCode); + } + + [Fact] + public async Task Without_a_receipt_account_sign_in_the_members_page_and_votes_still_work() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Member Only", ReceiptState.None); + var accountId = _factory.AccountId(identity); + + foreach (var path in new[] { "/App", "/App/Members", ConsentPath }) + { + using var page = await client.GetAsync(path); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + } + + var collection = "c" + Guid.NewGuid().ToString("N"); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject { Collection = collection, Key = "alpha", Title = "alpha", RegisteredAtUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + } + + using var vote = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up") { Content = new FormUrlEncodedContent([]) }; + vote.Headers.TryAddWithoutValidation("Origin", Origin.GetLeftPart(UriPartial.Authority)); + using var voted = await client.SendAsync(vote); + Assert.Equal(HttpStatusCode.Redirect, voted.StatusCode); + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Single(db.ConceptAppraisals.AsNoTracking().Where(x => x.Subject!.Collection == collection && x.UserAccountId == accountId)); + } + + Assert.Empty(_factory.ReceiptsOf(identity)); // none of that wrote an agreement + } + // ---- helpers ---- private static WebApplicationFactoryClientOptions ClientOptions() => new() @@ -502,17 +995,22 @@ public sealed class OidcIssuerTests : IClassFixture private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); - private static async Task RegisterAsync(HttpClient client, string identity) + private static async Task RegisterAsync(HttpClient client, string identity, bool agree = true) { - using var response = await client.PostAsJsonAsync("/auth/bifrost/register", new { identity, displayName = 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 8aa5d15..c2e5b0f 100644 --- a/tests/Bifrost.Web.Tests/OidcLogoutTests.cs +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -37,9 +37,7 @@ public sealed class OidcLogoutTests [Theory] [InlineData(MembershipStatus.Suspended)] - [InlineData(MembershipStatus.PendingApproval)] - [InlineData(MembershipStatus.Authenticated)] - public async Task A_member_who_is_not_active_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status) + public async Task A_suspended_account_gets_one_signed_logout_and_loses_the_authorizations(MembershipStatus status) { using var factory = new OidcLogoutFactory(); var (identity, idToken) = await factory.GrantAsync(status); @@ -78,6 +76,25 @@ public sealed class OidcLogoutTests Assert.Single(factory.Mas.Bodies); } + [Theory] + [InlineData(ReceiptState.Withdrawn)] + [InlineData(ReceiptState.OtherVersion)] + [InlineData(ReceiptState.None)] + public async Task An_account_whose_agreement_has_gone_is_logged_out_while_one_that_agreed_is_left_alone(ReceiptState gone) + { + using var factory = new OidcLogoutFactory(); + var (silenced, _) = await factory.GrantAsync(MembershipStatus.Active); + var (agreed, _) = await factory.GrantAsync(MembershipStatus.Active); + await factory.SetReceiptAsync(silenced, gone); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())); + Assert.Equal(factory.AccountId(silenced).ToString("D"), token.Subject); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(silenced).ToString("D"))); + Assert.True(factory.ValidAuthorizations(factory.AccountId(agreed).ToString("D")) > 0); + } + [Fact] public async Task Two_logouts_for_one_subject_carry_different_jti() { @@ -112,6 +129,23 @@ public sealed class OidcLogoutTests Assert.Equal(before, factory.ValidAuthorizations(activeSubject)); } + [Theory] + [InlineData(MembershipStatus.Authenticated)] + [InlineData(MembershipStatus.PendingApproval)] + public async Task A_chat_tier_non_member_is_never_sent_a_logout_and_keeps_the_authorizations(MembershipStatus status) + { + using var factory = new OidcLogoutFactory(); + var (chat, _) = await factory.GrantAsync(status); + var subject = factory.AccountId(chat).ToString("D"); + var before = factory.ValidAuthorizations(subject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Empty(factory.Mas.LogoutTokens()); + Assert.Equal(before, factory.ValidAuthorizations(subject)); + } + [Fact] public async Task One_subject_that_cannot_be_delivered_does_not_block_another() { diff --git a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs index dd7543b..7597ea0 100644 --- a/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs +++ b/tests/Bifrost.Web.Tests/PlatformEndpointsTests.cs @@ -45,16 +45,8 @@ public sealed class PlatformEndpointsTests : IClassFixture(); @@ -79,15 +71,8 @@ public sealed class PlatformEndpointsTests : IClassFixture "100"; + + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["Bifrost:Registration:Captcha:FallbackWaitSeconds"] = "30", + ["Bifrost:Registration:Limits:FallbackPermits"] = FallbackPermits + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Time); + }); + } +} + +public sealed class RegistrationFallbackBudgetFactory : RegistrationFormFactory +{ + protected override string FallbackPermits => "2"; +} + +internal static class FormKit +{ + public static string Hidden(string html, string name) + { + var match = Regex.Match(html, $" PageAsync(HttpClient client, string query = "") + { + using var response = await client.GetAsync("/auth/bifrost/register" + query); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return await response.Content.ReadAsStringAsync(); + } + + public const string SiteOrigin = "https://localhost"; + + // A browser on the site's own page sends its Origin; the guard refuses a post without one. + public static Task PostAsync(HttpClient client, Dictionary fields, string? origin = SiteOrigin, string? peer = null) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/auth/bifrost/register/form") { Content = new FormUrlEncodedContent(fields) }; + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + + if (peer is not null) + { + request.Headers.Add("X-Test-Peer", peer); + } + + return client.SendAsync(request); + } + + public static Dictionary Fields(string html, string identity, bool consent = true, string? nonce = null, bool waited = false, string? returnUrl = null) + { + var fields = new Dictionary + { + ["identity"] = identity, + ["displayName"] = "", + ["challenge"] = Hidden(html, "challenge"), + ["acceptedNoticeVersion"] = Hidden(html, "acceptedNoticeVersion"), + ["returnUrl"] = returnUrl ?? Hidden(html, "returnUrl") + }; + if (consent) + { + fields["consent"] = "yes"; + } + + if (nonce is not null) + { + fields["nonce"] = nonce; + } + + if (waited) + { + fields["waited"] = "true"; + } + + return fields; + } + + /// A nonce that provably fails this page's challenge: its hash lacks the leading zero bits the difficulty needs. + public static string Unsolved(string html) + { + var challenge = Hidden(html, "challenge"); + var difficulty = int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value); + // Starts at a long marker value so a test can check the nonce is never echoed. + for (var nonce = 7777777777L; ; nonce++) + { + var text = nonce.ToString(System.Globalization.CultureInfo.InvariantCulture); + if (RegistrationChallengeService.LeadingZeroBits(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(challenge + ":" + text))) < difficulty) + { + return text; + } + } + } + + public static string Solved(string html) => RegistrationKit.Solve(Hidden(html, "challenge"), int.Parse(Regex.Match(html, "data-difficulty=\"(\\d+)\"").Groups[1].Value)); +} + +public sealed class RegistrationFormTests(RegistrationFormFactory factory) : IClassFixture +{ + private static string NewIdentity() => $"form-{Guid.NewGuid():N}"; + + private HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + private (int Accounts, int Receipts) Counts() + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return (db.UserAccounts.Count(), db.ConsentReceipts.Count()); + } + + private bool Exists(string identity) + { + using var scope = factory.Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService().UserAccounts.Any(x => x.NormalizedBifrostIdentity == identity); + } + + private (bool Account, bool Receipt) Created(string identity) + { + using var scope = factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.SingleOrDefault(x => x.NormalizedBifrostIdentity == identity); + return (account is not null, account is not null && db.ConsentReceipts.Any(x => x.UserAccountId == account.Id && x.NoticeVersion == TrainingNotice.Version)); + } + + [Fact] + public async Task The_page_shows_the_whole_notice_beside_an_unchecked_consent_and_touches_nothing_outside() + { + using var client = Client(); + using var response = await client.GetAsync("/auth/bifrost/register"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + foreach (var paragraph in TrainingNotice.Text.Split("\n\n")) + { + Assert.Contains(WebUtility.HtmlEncode(paragraph), html); + } + + Assert.Contains($"version {TrainingNotice.Version}", html); + Assert.Contains("", html); + Assert.Matches("]*/>", html); + Assert.DoesNotMatch("]*\\bchecked\\b", html); + Assert.Contains("I agree to this notice, to speak in a recorded room", html); + Assert.Contains("Withdrawing ends future speaking there; lines you have already spoken stay.", html); + Assert.DoesNotMatch("]*\\brequired\\b", html); + foreach (var id in new[] { "identity", "displayName", "consent" }) + { + Assert.Matches($"]*for=\"{id}\"", html); + } + + Assert.Contains("
", html); + Assert.Contains("", html); + Assert.Contains("aria-live=\"polite\"", html); + Assert.Equal(TrainingNotice.Version.ToString(), FormKit.Hidden(html, "acceptedNoticeVersion")); + Assert.False(string.IsNullOrWhiteSpace(FormKit.Hidden(html, "challenge"))); + Assert.Matches("wait \\d+ seconds", html); + Assert.Matches("", html); + Assert.DoesNotContain("a few seconds", html); + Assert.DoesNotContain("nothing is lost", html); + Assert.Contains("tick it again if you want to agree", html); + Assert.DoesNotContain("().ConsentReceipts.Where(x => x.UserAccount!.NormalizedBifrostIdentity == identity)); + } + + [Fact] + public async Task The_consent_box_with_a_stale_version_returns_the_page_with_an_error_and_nothing_created() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var challenge = FormKit.Hidden(html, "challenge"); + var identity = NewIdentity(); + var before = Counts(); + var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); + fields["acceptedNoticeVersion"] = (TrainingNotice.Version + 1).ToString(); + + using var response = await FormKit.PostAsync(client, fields); + var again = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Matches("role=\"alert\"", again); + Assert.Matches("id=\"consent\"[^>]*aria-invalid=\"true\" aria-describedby=\"errors\"", again); + Assert.Contains("", again); + Assert.DoesNotMatch("]*\\bchecked\\b", again); + Assert.Contains($"value=\"{identity}\"", again); + Assert.NotEqual(challenge, FormKit.Hidden(again, "challenge")); + Assert.Equal(before, Counts()); + } + + [Theory] + [InlineData("abc")] + [InlineData("02")] + [InlineData("002")] + [InlineData("+2")] + [InlineData(" 2")] + public async Task The_consent_box_with_a_version_that_is_not_the_canonical_decimal_is_refused_not_read_as_no_consent(string posted) + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var identity = NewIdentity(); + var fields = FormKit.Fields(html, identity, nonce: FormKit.Solved(html)); + fields["acceptedNoticeVersion"] = posted; + + using var response = await FormKit.PostAsync(client, fields); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.False(Exists(identity)); + } + + [Fact] + public async Task A_wrong_nonce_fails_the_challenge_and_the_error_never_echoes_the_challenge_or_nonce() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + var challenge = FormKit.Hidden(html, "challenge"); + var identity = NewIdentity(); + + var nonce = FormKit.Unsolved(html); + + using var response = await FormKit.PostAsync(client, FormKit.Fields(html, identity, nonce: nonce)); + var again = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Registration challenge failed.", again); + Assert.DoesNotContain(challenge, again); + Assert.DoesNotContain(nonce, again); + Assert.False(Exists(identity)); + } + + [Fact] + public async Task An_identity_with_markup_is_escaped_when_the_page_comes_back() + { + using var client = Client(); + var html = await FormKit.PageAsync(client); + const string hostile = "\">"; + + using var response = await FormKit.PostAsync(client, FormKit.Fields(html, hostile, consent: false)); + var again = await response.Content.ReadAsStringAsync(); + + 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-open-direct.test.mjs b/tests/persona-matrix-mouth-open-direct.test.mjs new file mode 100644 index 0000000..cf5ee95 --- /dev/null +++ b/tests/persona-matrix-mouth-open-direct.test.mjs @@ -0,0 +1,344 @@ +// A Persona asks her mouth for a direct room with one person. The homeserver here keeps room state the way Synapse does +// (create event with its content, one member event per account, encryption event when set), so the mouth's lookup, +// the consent that lives in the person's own membership, and the order of its writes are exercised, not mocked. +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { createMouth } from "../tools/persona-matrix-mouth.mjs"; + +const HS = "gamecult.org"; +const ME = `@eureka:${HS}`; +const META = `@meta:${HS}`; +const PROJECT = "!project:gamecult.org"; +const ALIAS = `#swarm:${HS}`; + +async function homeserver({ people = [META, `@nova:${HS}`], now = () => 1000, failInvite: initialFailInvite = false } = {}) { + const config = { failInvite: initialFailInvite }; + const rooms = new Map(); // room id -> { events } + const stateReads = []; + const log = []; // every write, in order, as "METHOD path" + let counter = 0; + const direct = { value: null }; + const member = (room, user, membership) => { + const events = rooms.get(room).events.filter(e => !(e.type === "m.room.member" && e.state_key === user)); + events.push({ type: "m.room.member", state_key: user, origin_server_ts: now(), content: { membership } }); + rooms.get(room).events = events; + }; + const server = createServer((req, res) => { + let raw = ""; + req.on("data", c => (raw += c)); + req.on("end", () => { + const url = new URL(req.url, "http://x"); + const path = decodeURIComponent(url.pathname).replace("/_matrix/client/v3", ""); + const body = raw ? JSON.parse(raw) : null; + const reply = (status, json) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(json)); }; + const write = () => { if (!path.startsWith("/presence/") && !path.includes("/account_data/")) log.push(`${req.method} ${path}`); }; + if (path === "/account/whoami") return reply(200, { user_id: ME }); + if (path === "/joined_rooms") return reply(200, { joined_rooms: [PROJECT, ...[...rooms].filter(([, r]) => r.events.some(e => e.type === "m.room.member" && e.state_key === ME && e.content.membership === "join")).map(([id]) => id)] }); + if (path.includes("/account_data/m.direct")) { + if (req.method === "PUT") { direct.value = body; return reply(200, {}); } + return direct.value ? reply(200, direct.value) : reply(404, { errcode: "M_NOT_FOUND" }); + } + if (path.startsWith("/directory/room/")) return reply(200, { room_id: PROJECT }); + if (path.startsWith("/profile/")) return people.includes(path.slice("/profile/".length)) ? reply(200, { displayname: "x" }) : reply(404, { errcode: "M_NOT_FOUND" }); + const state = path.match(/^\/rooms\/(.+)\/state$/); + if (state) stateReads.push(state[1]); + if (state) return rooms.has(state[1]) ? reply(200, rooms.get(state[1]).events) : reply(403, {}); + if (req.method === "POST" && path === "/createRoom") { + write(); + const id = `!dm${++counter}:${HS}`; + rooms.set(id, { events: [{ type: "m.room.create", state_key: "", origin_server_ts: now() + counter, content: { creator: ME, ...body.creation_content } }] }); + if (body.preset !== "private_chat") return reply(400, {}); + member(id, ME, "join"); + rooms.get(id).body = body; + if (body.initial_state?.some(e => e.type === "m.room.encryption")) rooms.get(id).events.push({ type: "m.room.encryption", state_key: "", content: {} }); + return reply(200, { room_id: id }); + } + const invite = path.match(/^\/rooms\/(.+)\/invite$/); + if (invite) { write(); if (config.failInvite) return reply(403, { errcode: "M_FORBIDDEN" }); member(invite[1], body.user_id, "invite"); rooms.get(invite[1]).invited = body; return reply(200, {}); } + const put = path.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/); + if (put) { + write(); + const r = rooms.get(put[1]); + r.sent ??= new Map(); + if (!r.sent.has(put[2])) r.sent.set(put[2], { id: `$e${counter}-${r.sent.size + 1}`, body }); + return reply(200, { event_id: r.sent.get(put[2]).id }); + } + reply(404, { errcode: "M_NOT_FOUND" }); + }); + }); + await new Promise(ok => server.listen(0, "127.0.0.1", ok)); + return { + url: `http://127.0.0.1:${server.address().port}`, rooms, log, direct, stateReads, + set failInvite(v) { config.failInvite = v; }, + seedDm(id, user, membership, { encrypted = false, created = 5, marker } = {}) { + rooms.set(id, { events: [{ type: "m.room.create", state_key: "", origin_server_ts: created, content: { creator: ME, ...(marker ? { "org.gamecult.open_request": marker } : {}) } }] }); + member(id, ME, "join"); member(id, user, membership); + if (encrypted) rooms.get(id).events.push({ type: "m.room.encryption", state_key: "", content: {} }); + }, + set: member, + writes: pattern => log.filter(l => pattern.test(l)), + close: () => { server.closeAllConnections(); return new Promise(ok => server.close(ok)); }, + }; +} + +const memoryState = () => { const box = { value: null }; return { load: async () => box.value, save: async v => { box.value = v; } }; }; +const mouthFor = (hs, extra = {}) => createMouth({ persona: "eureka", homeserver: hs.url, serverName: HS, rooms: [ALIAS], token: "tok", state: memoryState(), brake: async () => true, syncTimeoutMs: 1, ...extra }); +const ask = (m, requestId, user = META, text = "hello Meta, may I ask you something?", persona = "eureka") => m.openDirect({ requestId, persona, userId: user, text }); +const outcome = r => r.outcome; + +test("a new person: creates an unencrypted private direct room, invites with the line as the reason, says the line with the not-E2EE notice, and answers Opened", async () => { + const hs = await homeserver(); + try { + const m = mouthFor(hs); + const r = await ask(m, "req-1"); + assert.equal(r.outcome, "Opened"); + const [roomId] = [...hs.rooms.keys()]; + assert.equal(r.roomId, roomId); + const room = hs.rooms.get(roomId); + assert.equal(room.body.preset, "private_chat"); + assert.equal(room.body.is_direct, true); + assert.equal(room.body.creation_content["m.federate"], false); + assert.equal(room.body.creation_content["org.gamecult.open_request"], "req-1"); + assert.ok(!JSON.stringify(room.body).includes("m.room.encryption"), "the room is created without encryption"); + assert.ok(!room.events.some(e => e.type === "m.room.encryption")); + assert.deepEqual(room.invited, { user_id: META, reason: "hello Meta, may I ask you something?" }); + const line = room.sent.get("req-1"); + assert.equal(r.eventId, line.id); + assert.match(line.body.body, /not end-to-end encrypted/, "the first message says so"); + assert.ok(line.body.body.endsWith("hello Meta, may I ask you something?")); + assert.deepEqual(hs.direct.value, { [META]: [roomId] }, "the room id is stored in m.direct"); + assert.deepEqual(hs.log.map(l => l.split(" ")[0] + " " + l.split("/").pop()), ["POST createRoom", "POST invite", "PUT req-1"], "order of writes"); + } finally { await hs.close(); } +}); + +test("the invite reason is the line cut to 300 bytes", async () => { + const hs = await homeserver(); + try { + await ask(mouthFor(hs), "req-1", META, "x".repeat(900)); + assert.equal(Buffer.byteLength([...hs.rooms.values()][0].invited.reason), 300); + } finally { await hs.close(); } +}); + +test("only her own request is served, and only for a person: mismatch, empty text, a foreign server, herself, another Mind, an unknown account", async () => { + const hs = await homeserver(); + try { + const nova = `@nova:${HS}`; + const m = mouthFor(hs, { isServed: id => id === nova }); + assert.equal(outcome(await ask(m, "r1", META, "hi", "nova")), "PersonaMismatch"); + assert.equal(outcome(await ask(m, "r2", META, " ")), "EmptyText"); + assert.equal(outcome(await ask(m, "r3", "@meta:matrix.org")), "ForeignUser"); + assert.equal(outcome(await ask(m, "r3b", "@meta:gamecult.org.evil.example")), "ForeignUser"); + assert.equal(outcome(await ask(m, "r4", ME)), "NotAPerson"); + assert.equal(outcome(await ask(m, "r5", nova)), "NotAPerson"); + assert.equal(outcome(await ask(m, "r6", `@nobody:${HS}`)), "NoSuchUser"); + assert.deepEqual(hs.log, [], "no write for any refusal"); + assert.equal(hs.rooms.size, 0); + } finally { await hs.close(); } +}); + +test("a held brake refuses before any Matrix call", async () => { + const hs = await homeserver(); + try { + const m = mouthFor(hs, { brake: async () => false }); + assert.equal(outcome(await ask(m, "r1")), "Brake"); + assert.deepEqual(hs.log, []); + assert.equal(hs.rooms.size, 0); + } finally { await hs.close(); } +}); + +test("the brake is read fresh before createRoom, before the invite and before the line: flipping it at any point stops every later write", async () => { + for (let free = 1; free <= 5; free++) { + const hs = await homeserver(); + try { + let reads = 0; + const events = []; + const m = mouthFor(hs, { brake: async () => { const ok = ++reads <= free; events.push(ok ? "free" : "held"); return ok; } }); + const originalLog = hs.log; + const push = originalLog.push.bind(originalLog); + originalLog.push = (...x) => { events.push(`write ${x[0]}`); return push(...x); }; + await ask(m, "r1"); + let held = false; + for (const e of events) { + if (e === "held") held = true; + else if (held && e.startsWith("write")) assert.fail(`free=${free}: a write after the brake was held: ${e}`); + } + const writes = events.filter(e => e.startsWith("write")); + for (let i = 0; i < events.length; i++) if (events[i].startsWith("write")) assert.equal(events[i - 1], "free", `free=${free}: ${events[i]} was not immediately preceded by a released brake read`); + assert.equal(writes.length, Math.min(3, free - 1), `free=${free}: reads 2, 3 and 4 each gate exactly the createRoom, the invite and the line`); + } finally { await hs.close(); } + } +}); + +test("a second request for a person who accepted says the line in the same room and answers Existing, with no new room", async () => { + const hs = await homeserver(); + try { + const m = mouthFor(hs); + const first = await ask(m, "r1"); + hs.set(first.roomId, META, "join"); // Meta accepts + const again = await ask(m, "r2", META, "second line"); + assert.deepEqual([again.outcome, again.roomId], ["Existing", first.roomId]); + assert.equal(hs.rooms.size, 1); + const sent = hs.rooms.get(first.roomId).sent.get("r2"); + assert.equal(again.eventId, sent.id); + assert.equal(sent.body.body, "second line", "no notice again after the first message"); + } finally { await hs.close(); } +}); + +test("a repeat of the same request id after a crash resumes in the room it made: one room, one line, Opened again", async () => { + const hs = await homeserver(); + try { + const first = await ask(mouthFor(hs), "r1"); + const again = await ask(mouthFor(hs), "r1"); // a restarted process: no memory of the first + assert.deepEqual([again.outcome, again.roomId, again.eventId], ["Opened", first.roomId, first.eventId]); + assert.equal(hs.rooms.size, 1); + assert.equal(hs.writes(/createRoom/).length, 1); + assert.equal(hs.writes(/\/invite/).length, 1); + } finally { await hs.close(); } +}); + +test("an invite the person has not answered is InvitePending: nothing new is written", async () => { + const hs = await homeserver(); + try { + const m = mouthFor(hs); + await ask(m, "r1"); + const before = hs.log.length; + const again = await ask(m, "r2"); + assert.equal(again.outcome, "InvitePending"); + assert.equal(hs.log.length, before); + } finally { await hs.close(); } +}); + +test("a decline stands: leaving or banning is Declined, for a restarted mouth too, until the person invites her", async () => { + for (const gone of ["leave", "ban"]) { + const hs = await homeserver(); + try { + const first = await ask(mouthFor(hs), "r1"); + hs.set(first.roomId, META, gone); + const before = hs.log.length; + for (const m of [mouthFor(hs), mouthFor(hs)]) { + assert.equal(outcome(await ask(m, `again-${Math.random()}`)), "Declined", gone); + } + assert.equal(hs.log.length, before, "no room, invite or line"); + // the person writes first: she is invited into a new room and joins; now there is a live DM + hs.seedDm("!theirs:gamecult.org", META, "join", { created: 99999 }); + const answer = await ask(mouthFor(hs), "r-after"); + assert.deepEqual([answer.outcome, answer.roomId], ["Existing", "!theirs:gamecult.org"]); + } finally { await hs.close(); } + } +}); + +test("an invite the homeserver refuses leaves the room made; the retry invites into that same room instead of making another", async () => { + const hs = await homeserver({ failInvite: true }); + try { + assert.equal(outcome(await ask(mouthFor(hs), "r1")), "SendFailed"); + assert.equal(hs.rooms.size, 1); + hs.failInvite = false; + const retry = await ask(mouthFor(hs), "r2"); + assert.equal(retry.outcome, "Opened"); + assert.equal(hs.rooms.size, 1, "no second room"); + assert.equal(retry.roomId, [...hs.rooms.keys()][0]); + assert.equal(hs.rooms.get(retry.roomId).invited.user_id, META); + } finally { await hs.close(); } +}); + +test("one creation per person per day: a second creation the same UTC day is DailyLimit, the next UTC day it is allowed", async () => { + let t = Date.parse("2026-11-10T10:00:00Z"); + const hs = await homeserver({ now: () => t }); + try { + const m = mouthFor(hs, { now: () => t }); + assert.equal(outcome(await ask(m, "r1")), "Opened"); + hs.rooms.clear(); // she left the room: no DM with them is found now, only the day's record stands + assert.equal(outcome(await ask(m, "r2")), "DailyLimit"); + assert.equal(hs.writes(/createRoom/).length, 1); + t += 24 * 3600 * 1000; + assert.equal(outcome(await ask(m, "r3")), "Opened"); + assert.equal(outcome(await ask(m, "r4", `@nova:${HS}`)), "Opened", "the limit is per person"); + } finally { await hs.close(); } +}); + +test("an encrypted direct room is not used: the line is never sent into it and a new unencrypted room is made", async () => { + const hs = await homeserver(); + try { + hs.seedDm("!old:gamecult.org", META, "join", { encrypted: true }); + const r = await ask(mouthFor(hs), "r1"); + assert.equal(r.outcome, "Opened"); + assert.notEqual(r.roomId, "!old:gamecult.org"); + assert.equal(hs.rooms.get("!old:gamecult.org").sent, undefined); + } finally { await hs.close(); } +}); + +test("a homeserver failure answers SendFailed and writes nothing the brake or a refusal should have stopped", async () => { + const hs = await homeserver(); + try { + const m = mouthFor(hs, { homeserver: "http://127.0.0.1:9" }); + assert.equal(outcome(await ask(m, "r1")), "SendFailed"); + } finally { await hs.close(); } +}); + +test("two requests for one person at once make one room: opens for a person run one at a time", async () => { + const hs = await homeserver(); + try { + const m = mouthFor(hs); + const answers = await Promise.all([ask(m, "a"), ask(m, "b")]); + assert.equal(hs.writes(/createRoom/).length, 1); + assert.deepEqual(answers.map(outcome).sort(), ["InvitePending", "Opened"]); + const other = await Promise.all([ask(m, "c", `@nova:${HS}`), ask(m, "d", META)]); + assert.equal(other[0].outcome, "Opened", "another person is not held behind"); + } finally { await hs.close(); } +}); + +test("a decline stands in an encrypted room too, and a pending invite there is still pending: no new room, no line", async () => { + for (const [membership, expected] of [["leave", "Declined"], ["invite", "InvitePending"]]) { + const hs = await homeserver(); + try { + hs.seedDm("!enc:gamecult.org", META, membership, { encrypted: true }); + assert.equal(outcome(await ask(mouthFor(hs), "r1")), expected); + assert.deepEqual(hs.log, []); + } finally { await hs.close(); } + } +}); + +test("the not-E2EE notice reaches the person even when the first line was held back: a later line into the room this path made carries it once", async () => { + const hs = await homeserver(); + try { + let reads = 0; + const m = mouthFor(hs, { brake: async () => ++reads <= 3 }); // top, createRoom, invite: then the brake is held before the line + assert.equal(outcome(await ask(m, "r1")), "Brake"); + const roomId = [...hs.rooms.keys()][0]; + assert.equal(hs.rooms.get(roomId).sent, undefined, "the invite went out, the line did not"); + hs.set(roomId, META, "join"); + const answer = await ask(mouthFor(hs), "r2", META, "second"); + assert.deepEqual([answer.outcome, answer.roomId], ["Existing", roomId]); + assert.match(hs.rooms.get(roomId).sent.get("r2").body.body, /^This room is not end-to-end encrypted.*\n\nsecond$/s); + await ask(mouthFor(hs), "r3", META, "third"); // another process: the notice is repeated at worst once, never lost + const sameProcess = mouthFor(hs); + await ask(sameProcess, "r4", META, "fourth"); + await ask(sameProcess, "r5", META, "fifth"); + assert.equal(hs.rooms.get(roomId).sent.get("r5").body.body, "fifth", "once the notice went out in this process it is not repeated"); + } finally { await hs.close(); } +}); + +test("the lookup finds a DM only: a group room with the person is not one, and project rooms are never scanned", async () => { + const hs = await homeserver(); + try { + hs.seedDm("!group:gamecult.org", META, "join"); + hs.set("!group:gamecult.org", `@third:${HS}`, "join"); // three members + const r = await ask(mouthFor(hs), "r1"); + assert.equal(r.outcome, "Opened"); + assert.notEqual(r.roomId, "!group:gamecult.org"); + assert.equal(hs.rooms.get("!group:gamecult.org").sent, undefined); + assert.ok(!hs.stateReads.includes("!project:gamecult.org"), "a configured project room is not read as a DM candidate"); + } finally { await hs.close(); } +}); + +test("another Mind this process serves is not a person, even before that Mind's own mouth has started", async () => { + const hs = await homeserver(); + try { + const nova = `@nova:${HS}`; + let asked = null; + const m = mouthFor(hs, { isServed: async id => { asked = id; return id === nova; } }); + assert.equal(outcome(await ask(m, "r1", nova)), "NotAPerson"); + assert.equal(asked, nova); + } finally { await hs.close(); } +}); diff --git a/tests/persona-matrix-mouth-runtime.test.mjs b/tests/persona-matrix-mouth-runtime.test.mjs index d53de8e..f231758 100644 --- a/tests/persona-matrix-mouth-runtime.test.mjs +++ b/tests/persona-matrix-mouth-runtime.test.mjs @@ -1,9 +1,10 @@ // The parts of the Matrix mouth that sit on CultLib: the chat_* wire documents, the // CultCache cursor and brake stores, and the CultNet RUDP endpoint. Needs the built -// CultLib TypeScript packages (CULTLIB_ROOT, else ../CultLib), like the other Bifrost tests. CultLib's dist/ is gitignored: +// CultLib TypeScript packages (VOIDBOT_CULTLIB_ROOT, else ../CultLib), like the other Bifrost tests. CultLib's dist/ is gitignored: // run `npm ci && npm run build:ts` there first. Since CultLib 286a9af the packages are @gamecult/cultcache-ts, // @gamecult/cultnet-ts and @gamecult/cultmesh-ts; the directories under packages/ keep their short names. import test from "node:test"; +import { cultLibRoot } from "./cultlib-root.mjs"; import assert from "node:assert/strict"; import dgram from "node:dgram"; import { createServer } from "node:http"; @@ -15,10 +16,11 @@ import * as docs from "../tools/persona-chat-documents.mjs"; import { createMouth, cultcacheState, connectionIdFor, createBrakeReader, loadRuntime, pageOf, startChatEndpoint } from "../tools/persona-matrix-mouth.mjs"; const root = resolve(import.meta.dirname, ".."); -const runtime = loadRuntime(resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib"))); +const runtime = loadRuntime(cultLibRoot); const { msgpack } = runtime; const fixtureDir = resolve(import.meta.dirname, "fixtures", "persona-chat-v1"); +const tailLine = { roomAlias: "#operator:gamecult.org", eventId: "$t1", atUnixMs: 1_700_000_000_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "earlier" }; const delivery = { seq: 7, roomAlias: "#operator:gamecult.org", roomId: "!abc:gamecult.org", eventId: "$e1", sender: "@meta:gamecult.org", senderDisplay: "Meta", text: "hello eureka", atUnixMs: 1761700000000, @@ -92,6 +94,7 @@ test("the brake reader reads a real CultCache control store", async () => { test("two clients served at once each get their own answer on their own session", async () => { const mouth = { pendingAfter: () => [], + tail: async () => ({ lines: [], gaps: [], rooms: [] }), ack: async () => {}, // Slow enough that the two requests are in flight together. send: async request => { await new Promise(r => setTimeout(r, 150)); return { requestId: request.requestId, eventId: `$${request.requestId}`, refusal: null }; }, @@ -137,6 +140,8 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a const calls = { acks: [], sends: [], reads: [] }; const mouth = { pendingAfter: after => (after < 7 ? [delivery] : []), + tail: async () => { if (calls.tailFails) throw new Error("room read failed"); return { lines: [tailLine], gaps: ["#operator:gamecult.org"], rooms: ["#operator:gamecult.org", "#swarm:gamecult.org"] }; }, + speakableRooms: () => ["#operator:gamecult.org", "#swarm:gamecult.org"], ack: async through => { calls.acks.push(through); }, read: async request => { calls.reads.push(request); return null; }, send: async request => { calls.sends.push(request); return request.text === "muted" ? { requestId: request.requestId, eventId: null, refusal: "Brake" } : { requestId: request.requestId, eventId: "$sent", refusal: null }; }, @@ -171,6 +176,26 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a const pending = docs.parsePending(msgpack.decode(document.payload)); assert.deepEqual(pending.deliveries.map(d => d.eventId), ["$e1"]); assert.deepEqual(pending.deliveries[0], delivery, "sender, mentions and the DM flag cross the wire exactly"); + assert.deepEqual(Buffer.from(document.payload), await readFile(resolve(root, "tests", "fixtures", "persona-chat-v3", "gamecult.persona.chat_pending.v2.no-image.msgpack")), "a v1 request is answered with the two-field v2, byte for byte the golden, whatever the tail gaps"); + + reply = nextReply(); + put(docs.kinds.pendingRequestV3, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + document = await reply; + assert.equal(document.schemaId, docs.kinds.pendingV3, "the kind asked by names the answer"); + const v3 = docs.parsePendingV3(msgpack.decode(document.payload)); + assert.deepEqual(v3.deliveries[0], { ...delivery, basis: null, form: "message" }); + assert.deepEqual(v3.tail, [tailLine], "the v3 answer carries the room tail"); + assert.deepEqual(v3.tailGaps, ["#operator:gamecult.org"], "and the rooms whose tail is incomplete"); + + calls.tailFails = true; + reply = nextReply(); + put(docs.kinds.pendingRequestV3, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + document = await reply; + const degraded = docs.parsePendingV3(msgpack.decode(document.payload)); + assert.equal(degraded.deliveries.length, 1, "a tail that throws does not silence the deliveries"); + assert.deepEqual(degraded.tail, []); + assert.deepEqual(degraded.tailGaps, ["#operator:gamecult.org", "#swarm:gamecult.org"], "a tail that throws leaves every speakable room a gap, not quiet"); + calls.tailFails = false; put(docs.kinds.ack, "eureka:7", docs.ackTuple({ persona: "eureka", throughSeq: 7 })); reply = nextReply(); @@ -256,7 +281,7 @@ test("one process serves N Personas from systemd credentials: each token only in probe.close(); const child = spawn(process.execPath, [resolve(root, "tools", "persona-matrix-mouth.mjs")], { env: { - PATH: process.env.PATH, CULTLIB_ROOT: resolve(process.env.CULTLIB_ROOT ?? resolve(root, "..", "CultLib")), PERSONAS: "eureka,nova", CREDENTIALS_DIRECTORY: creds, CONTROL_ROOT: control, + PATH: process.env.PATH, CULTLIB_ROOT: cultLibRoot, PERSONAS: "eureka,nova", CREDENTIALS_DIRECTORY: creds, CONTROL_ROOT: control, STATE_DIRECTORY: stateDir, MATRIX_HOMESERVER: `http://127.0.0.1:${hs.address().port}`, MATRIX_ROOMS: "#swarm:t", MATRIX_SERVER_NAME: "t", CHAT_RUDP_LISTEN: `127.0.0.1:${port}`, }, }); @@ -310,6 +335,34 @@ for (const [name, { tuple, parse }] of Object.entries(v3Samples)) { }); } +// The pending answer v3 (ChatDelivery v4, basis and form): the bytes Huginn pins in its crates/mind-body/tests/fixtures/persona-chat-v5/. +const v5Dir = resolve(root, "tests", "fixtures", "persona-chat-v5"); +const v5Samples = { + "gamecult.persona.chat_pending_request.v2": { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest }, + "gamecult.persona.chat_pending.v3.tag": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [{ ...delivery, isDirect: false, basis: "tag", form: "message" }] }), parse: docs.parsePendingV3 }, + "gamecult.persona.chat_pending.v3.tail": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [], tail: [ + { roomAlias: "#operator:gamecult.org", eventId: "$e0", atUnixMs: 1_700_000_000_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "earlier" }, + { roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 1_700_000_001_000, sender: "@meta:gamecult.org", form: "notice", basis: "tag", text: "hello eureka" }, + ] }), parse: docs.parsePendingV3 }, + "gamecult.persona.chat_pending.v3.tail-gaps": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [], tail: [ + { roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 1_700_000_001_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "hello eureka" }, + ], tailGaps: ["#swarm:gamecult.org", "!dm1:gamecult.org"] }), parse: docs.parsePendingV3 }, + "gamecult.persona.chat_pending.v3.unproven-notice": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [{ ...delivery, isDirect: false, mentions: [], basis: null, form: "notice" }] }), parse: docs.parsePendingV3 }, +}; +for (const [name, { tuple, parse }] of Object.entries(v5Samples)) { + test(`golden fixture: ${name} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(v5Dir, `${name}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v5Dir, { recursive: true }); await writeFile(file, bytes); } + assert.deepEqual(bytes, await readFile(file), "wire bytes changed: Huginn decodes these fixtures"); + assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple)); + if (name.includes("pending.v3")) assert.equal(msgpack.decode(bytes).length, 4, "persona, deliveries, tail, tail_gaps"); + if (name.endsWith("v3.tag") || name.endsWith("unproven-notice")) assert.equal(msgpack.decode(bytes)[1][0].length, 14); + if (name.endsWith("v3.tail")) { assert.equal(msgpack.decode(bytes)[2][0].length, 7); assert.deepEqual(msgpack.decode(bytes)[3], [], "no gap: every room read fully"); } + if (name.endsWith("v3.tail-gaps")) assert.deepEqual(msgpack.decode(bytes)[3], ["#swarm:gamecult.org", "!dm1:gamecult.org"]); + }); +} + // The send pair v2: the bytes the Huginn organ pins from its side. const v2Dir = resolve(root, "tests", "fixtures", "persona-chat-v2"); const v2Request = basis => docs.sendRequestV2Tuple({ requestId: "req-1", persona: "eureka", roomAlias: "#swarm:gamecult.org", text: "hi", basis }); @@ -345,7 +398,7 @@ test("RoomMoved is v2 only, and the v2 documents are strict about arity and newe test("the RUDP endpoint answers a v1 send with the three-field receipt and a v2 send with the four-field one", async () => { const seen = []; - const mouth = { pendingAfter: () => [], ack: async () => {}, send: async request => { + const mouth = { pendingAfter: () => [], tail: async () => ({ lines: [], gaps: [], rooms: [] }), ack: async () => {}, send: async request => { seen.push(request); return request.basis ? { requestId: request.requestId, eventId: null, refusal: "RoomMoved", newer: ["$a"] } : { requestId: request.requestId, eventId: "$sent", refusal: null, newer: [] }; } }; @@ -438,3 +491,277 @@ test("over the RUDP endpoint: pending and an ack post no receipt, a read report try { client.close(); } catch { /* already closed */ } } }); + +// The open-direct pair (v4): the bytes Huginn pins in its crates/mind-body/tests/fixtures/persona-chat-v4/. +const v4Dir = resolve(root, "tests", "fixtures", "persona-chat-v4"); +const v4Samples = { + "gamecult.persona.chat_open_direct_request.v1": { tuple: docs.openDirectRequestTuple({ requestId: "req-1", persona: "eureka", userId: "@meta:gamecult.org", text: "hello Meta" }), parse: docs.parseOpenDirectRequest }, + "gamecult.persona.chat_open_direct_receipt.v1.opened": { tuple: docs.openDirectReceiptTuple({ requestId: "req-1", outcome: "Opened", roomId: "!dm1:gamecult.org", eventId: "$line1" }), parse: docs.parseOpenDirectReceipt }, + "gamecult.persona.chat_open_direct_receipt.v1.existing": { tuple: docs.openDirectReceiptTuple({ requestId: "req-2", outcome: "Existing", roomId: "!dm1:gamecult.org", eventId: "$line2" }), parse: docs.parseOpenDirectReceipt }, + "gamecult.persona.chat_open_direct_receipt.v1.declined": { tuple: docs.openDirectReceiptTuple({ requestId: "req-3", outcome: "Declined" }), parse: docs.parseOpenDirectReceipt }, + "gamecult.persona.chat_refusal.v1.unknown-kind": { tuple: docs.refusalTuple({ persona: "eureka", refusal: "UnknownKind" }), parse: docs.parseRefusal }, +}; +for (const [name, { tuple, parse }] of Object.entries(v4Samples)) { + test(`golden fixture: ${name} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(v4Dir, `${name}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v4Dir, { recursive: true }); await writeFile(file, bytes); } + assert.deepEqual(bytes, await readFile(file), "wire bytes changed: Huginn decodes these fixtures"); + assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple)); + }); +} + +test("the open-direct documents are strict: room and event ids exactly for Opened and Existing, a known outcome, a bounded line", () => { + for (const outcome of docs.directOutcomes) { + const placed = outcome === "Opened" || outcome === "Existing"; + assert.doesNotThrow(() => docs.openDirectReceiptTuple(placed ? { requestId: "r", outcome, roomId: "!a:b", eventId: "$e" } : { requestId: "r", outcome }), outcome); + assert.throws(() => docs.openDirectReceiptTuple(placed ? { requestId: "r", outcome } : { requestId: "r", outcome, roomId: "!a:b", eventId: "$e" }), /exactly for Opened and Existing/, outcome); + assert.throws(() => docs.parseOpenDirectReceipt(["r", outcome, placed ? "!a:b" : null, placed ? null : "$e"]), /exactly for/, `${outcome}: event id alone is half a placement`); + } + assert.throws(() => docs.openDirectReceiptTuple({ requestId: "r", outcome: "Invented" }), /unknown open-direct outcome/); + assert.throws(() => docs.parseOpenDirectRequest(["r", "eureka", "@a:b", "x".repeat(4001)]), /byte bound/); + assert.throws(() => docs.parseOpenDirectRequest(["r", "eureka", "", "x"]), /user_id/); + assert.throws(() => docs.parseOpenDirectRequest(["r", "eureka", "@a:b"]), /4-field/); + assert.throws(() => docs.sendReceiptTuple({ requestId: "r", refusal: "UnknownKind" }), /unknown refusal/, "UnknownKind exists only in the typed refusal document"); +}); + +test("the RUDP endpoint answers an open-direct request with a receipt for its Persona, refuses another Persona's and answers a request of an unknown kind loudly", async () => { + const opened = []; + const mouth = { openDirect: async request => { opened.push(request); return { requestId: request.requestId, outcome: "Opened", roomId: "!dm1:gamecult.org", eventId: "$line1" }; } }; + const key = "connection-key-for-test"; + const logged = []; + const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test", log: line => logged.push(line) }); + 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-test", socket: client, mode: "client", remoteHost: "127.0.0.1", remotePort: server.address.port, + connectionId: connectionIdFor(key), initialSequence: 1, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200, + }); + const replies = []; + const waiting = []; + connection.on("frame", value => { + replies.push(runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0").document); + waiting.splice(0).forEach(w => w()); + }); + const nextReply = () => new Promise((ok, fail) => { const t = setTimeout(() => fail(new Error(`no reply; the mouth logged: ${logged.join(" | ")}`)), 5000); waiting.push(() => { clearTimeout(t); ok(replies.at(-1)); }); }); + const put = (kind, recordKey, tuple, agent = "eureka") => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-test", sourceAgentId: agent, sourceRole: "persona-organ" }), "cultnet.schema.v0"))); + try { + connection.connect(); + for (let i = 0; i < 100 && !connection.connected; i++) await new Promise(r => setTimeout(r, 20)); + assert.ok(connection.connected); + let reply = nextReply(); + put(docs.kinds.openDirectRequest, "req-1", docs.openDirectRequestTuple({ requestId: "req-1", persona: "eureka", userId: "@meta:gamecult.org", text: "hello" })); + let document = await reply; + assert.equal(document.schemaId, docs.kinds.openDirectReceipt); + assert.deepEqual(docs.parseOpenDirectReceipt(msgpack.decode(document.payload)), { requestId: "req-1", outcome: "Opened", roomId: "!dm1:gamecult.org", eventId: "$line1" }); + assert.deepEqual(opened, [{ requestId: "req-1", persona: "eureka", userId: "@meta:gamecult.org", text: "hello" }]); + + reply = nextReply(); + put(docs.kinds.openDirectRequest, "req-2", docs.openDirectRequestTuple({ requestId: "req-2", persona: "other", userId: "@meta:gamecult.org", text: "hello" }), "other"); + document = await reply; + assert.deepEqual(docs.parseOpenDirectReceipt(msgpack.decode(document.payload)), { requestId: "req-2", outcome: "UnknownPersona", roomId: null, eventId: null }); + assert.equal(opened.length, 1, "nothing reached a mouth for the unlisted Persona"); + + reply = nextReply(); + put("gamecult.persona.chat_invented_request.v9", "req-3", ["x"]); + document = await reply; + assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload))], [docs.kinds.refusal, { persona: "eureka", refusal: "UnknownKind" }], "an unknown kind is answered, typed"); + assert.ok(logged.some(l => /unknown kind: gamecult\.persona\.chat_invented_request\.v9/.test(l)), "and logged"); + } finally { + server.close(); + connection.close(); + try { client.close(); } catch { /* the connection already closed its socket */ } + } +}); + +test("tail_gaps is additive: a three-field reader finds persona, deliveries and tail unchanged in the first three elements of the new bytes", async () => { + const sample = { persona: "eureka", deliveries: [{ ...delivery, isDirect: false, basis: "tag", form: "message" }], tail: [{ roomAlias: "#operator:gamecult.org", eventId: "$e0", atUnixMs: 1_700_000_000_000, sender: "@meta:gamecult.org", form: "message", basis: null, text: "earlier" }] }; + const noGaps = msgpack.decode(msgpack.encode(docs.pendingV3Tuple(sample))); + const after = msgpack.decode(await readFile(resolve(root, "tests", "fixtures", "persona-chat-v5", "gamecult.persona.chat_pending.v3.tail-gaps.msgpack"))); + const withGaps = msgpack.decode(msgpack.encode(docs.pendingV3Tuple({ ...sample, tailGaps: ["#swarm:gamecult.org"] }))); + assert.equal(withGaps.length, 4); + assert.deepEqual(withGaps.slice(0, 3), noGaps.slice(0, 3), "persona, deliveries and tail are byte-for-meaning the same with or without gaps"); + assert.deepEqual(docs.parsePendingV3([...withGaps.slice(0, 3), []]).tail, docs.parsePendingV3(noGaps).tail); + assert.equal(after.length, 4); + // 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 1e33f64..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, 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"; @@ -1303,3 +1303,830 @@ test("a read report that throws does not wedge the room: the next report still p assert.deepEqual(unhandled, [], "no rejection escapes the tail's cleanup"); assert.equal(hs.receipts().length, 1); }); + +// Addressing facts (docs/bridge.md, "Addressing facts"): why a delivery is for this Persona, as the heartbeat reads it. +// Each case is one message as a room member sends it, with the basis and form the mouth hands the consumer. +const META = `@meta:${HS_NAME}`; +const withContent = (id, sender, content, ts = 3000) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", ...content } }); +async function deliver(events, { persona = {}, preload } = {}) { + const hs = await fakeHomeserver({ + preload, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) }, + }); + try { + const mouth = mouthFor(hs, persona); + await mouth.syncOnce(); + return { made: await mouth.syncOnce(), hs, mouth }; + } finally { await hs.close(); } +} +const facts = made => made.map(d => [d.eventId, d.basis, d.form]); + +test("addressing facts: the operator speaking untagged proves nothing, tagged proves a tag, and the tag outranks @room", async () => { + const { made } = await deliver([ + withContent("$plain", META, { body: "everyone, status?" }), + withContent("$tag", META, { body: "eureka, status?", "m.mentions": { user_ids: [SELF] } }), + withContent("$both", META, { body: "all hands", "m.mentions": { user_ids: [SELF], room: true } }), + withContent("$room", META, { body: "@room standup", "m.mentions": { room: true } }), + ]); + assert.deepEqual(facts(made), [["$plain", null, "message"], ["$tag", "tag", "message"], ["$both", "tag", "message"], ["$room", "room", "message"]]); +}); + +test("addressing facts: a tag of another Mind is no basis for this one, and is one for that one", async () => { + const hs = await fakeHomeserver({ + users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA }, + sync: since => since === null ? { next_batch: "b0", ...joined({ [ROOM]: { timeline: { events: [] } } }) } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [withContent("$n", META, { body: "nova?", "m.mentions": { user_ids: [NOVA] } })] } } }) }, + }); + try { + const eureka = mouthFor(hs); + const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA }); + for (const m of [eureka, nova]) await m.syncOnce(); + assert.deepEqual([facts(await eureka.syncOnce()), facts(await nova.syncOnce())], [[["$n", null, "message"]], [["$n", "tag", "message"]]]); + } finally { await hs.close(); } +}); + +test("addressing facts: a name in the text is not a basis; the mouth does not know names, so every casing and alias reaches the heartbeat with null", async () => { + const bodies = ["Eureka, look", "EUREKA", "eureka's PR", "@eureka:gamecult.test please", "Bifrost needs a look"]; + const { made } = await deliver(bodies.map((body, i) => withContent(`$n${i}`, META, { body }))); + assert.deepEqual(made.map(d => d.basis), [null, null, null, null, null]); + assert.deepEqual(made.map(d => d.text), bodies, "the text is delivered whole for the heartbeat's own name match"); +}); + +test("addressing facts: a DM is direct whatever it says or carries, a notice in it included", async () => { + const DM = "!dm:gamecult.test"; + const hs = await fakeHomeserver({ + direct: { [META]: [DM] }, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [DM]: { timeline: { events: [withContent("$dm", META, { body: "hi" }), withContent("$dmn", META, { msgtype: "m.notice", body: "automated" })] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + assert.deepEqual(facts(await mouth.syncOnce()), [["$dm", "direct", "message"], ["$dmn", "direct", "notice"]]); + } finally { await hs.close(); } +}); + +test("addressing facts: a notice that tags her carries the tag and the notice form, so the consumer can refuse to wake on it", async () => { + const { made } = await deliver([withContent("$w", `@root-watch:${HS_NAME}`, { msgtype: "m.notice", body: "disk 91%", "m.mentions": { user_ids: [SELF] } })]); + assert.deepEqual(facts(made), [["$w", "tag", "notice"]]); +}); + +test("addressing facts: an edit is its own form and is never read as a new message", async () => { + const { made } = await deliver([withContent("$e", META, { body: "* fixed", "m.new_content": { msgtype: "m.text", body: "fixed" }, "m.relates_to": { rel_type: "m.replace", event_id: "$orig" } })]); + assert.deepEqual(facts(made), [["$e", null, "edit"]]); +}); + +test("addressing facts: a reply to her own words is a reply, in a room and in a thread, and her Self run's words are hers", async () => { + const mine = withContent("$mine", SELF, { body: "my earlier say" }, 1000); + const theirs = withContent("$theirs", `@nibu:${HS_NAME}`, { body: "someone else's" }, 1001); + const reply = to => ({ "m.relates_to": { "m.in_reply_to": { event_id: to } } }); + const thread = (root, fallback) => ({ "m.relates_to": { rel_type: "m.thread", event_id: root, "m.in_reply_to": { event_id: fallback }, is_falling_back: true } }); + const { made } = await deliver([ + withContent("$r1", META, { body: "yes", ...reply("$mine") }), + withContent("$r2", META, { body: "no", ...reply("$theirs") }), + withContent("$r3", META, { body: "in thread under mine", ...thread("$mine", "$theirs") }), + withContent("$r4", META, { body: "in thread under theirs", ...thread("$theirs", "$theirs") }), + withContent("$r5", META, { body: "reply, and tagged", ...reply("$mine"), "m.mentions": { user_ids: [SELF] } }), + ], { preload: { [ROOM]: [mine, theirs] } }); + assert.deepEqual(facts(made), [["$r1", "reply", "message"], ["$r2", null, "message"], ["$r3", "reply", "message"], ["$r4", null, "message"], ["$r5", "tag", "message"]]); +}); + +test("addressing facts: a reply target the homeserver will not give is null and logged without the token, never a guess", async () => { + const logs = []; + const { made } = await deliver([withContent("$r", META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: "$gone" } } })], { persona: { log: l => logs.push(l) } }); + assert.deepEqual(facts(made), [["$r", null, "message"]]); + assert.ok(logs.some(l => l.includes("reply target $gone not read")) && logs.every(l => !l.includes(TOKEN))); +}); + +test("addressing facts: a client that sends no m.mentions is a tag when its body links her matrix.to pill; one that sends m.mentions without her is not", async () => { + const pill = id => `Eureka: look`; + const { made } = await deliver([ + withContent("$p1", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(SELF) }), + withContent("$p2", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(encodeURIComponent(SELF)) }), + withContent("$p3", META, { body: "Nova: look", format: "org.matrix.custom.html", formatted_body: pill(NOVA) }), + withContent("$p4", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(SELF), "m.mentions": {} }), + ]); + assert.deepEqual(made.map(d => d.basis), ["tag", "tag", null, null]); +}); + +test("addressing facts: her own messages are no delivery at all, so no basis can wake her on her own words", async () => { + const { made } = await deliver([withContent("$self", SELF, { body: "my own", "m.mentions": { user_ids: [SELF] } })]); + assert.deepEqual(made, []); +}); + +test("addressing facts: the v3 answer carries basis and form; the v2 answer a v1 request gets is the unchanged 12 fields", async () => { + const { made } = await deliver([withContent("$t", META, { body: "x", "m.mentions": { user_ids: [SELF] } })]); + const pack = { encode: v => Buffer.from(JSON.stringify(v)) }; + assert.equal(docs.parsePendingV3(pageOf("eureka", made, pack, () => {}, true)).deliveries[0].basis, "tag"); + const v2 = pageOf("eureka", made, pack); + assert.equal(v2[1][0].length, 12); + assert.equal(docs.parsePending(v2).deliveries[0].basis, undefined); + assert.throws(() => docs.parseDeliveryV3([...docs.deliveryTuple(made[0]), "tag", "poem"]), /unknown delivery form/); + assert.throws(() => docs.parseDeliveryV3([...docs.deliveryTuple(made[0]), "everyone", "message"]), /unknown delivery basis/); + assert.throws(() => docs.parseDeliveryV3(docs.deliveryTuple(made[0])), /14-field/); +}); + +test("addressing facts: a hit read for a reply in one room is not lent to a reply in another room, and a target the server places elsewhere is null", async () => { + const mine = { ...withContent("$mine", SELF, { body: "mine" }, 1000), room_id: ROOM }; + const reply = id => withContent(id, META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: "$mine" } } }); + const hs = await fakeHomeserver({ + preload: { [ROOM]: [mine], "!operator:gamecult.test": [{ ...mine, room_id: "!elsewhere:gamecult.test" }] }, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [reply("$prime")] } }, "!operator:gamecult.test": { timeline: { events: [reply("$spoof")] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + assert.deepEqual(facts(await mouth.syncOnce()), [["$prime", "reply", "message"], ["$spoof", null, "message"]]); + } finally { await hs.close(); } +}); + +test("addressing facts: a pill is her exact matrix.to anchor: a longer id, a non-link attribute and a quoted reply are no tag", async () => { + const html = h => ({ body: "x", format: "org.matrix.custom.html", formatted_body: h }); + const { made } = await deliver([ + withContent("$a", META, html(`x`)), + withContent("$b", META, html(`x`)), + withContent("$c", META, html(`Eureka`)), + withContent("$d", META, html(`
her said
ok`)), + ]); + assert.deepEqual(made.map(d => d.basis), [null, null, "tag", null]); +}); + +test("addressing facts: an edit or a notice replying to her costs no target fetch", async () => { + const to = id => ({ "m.relates_to": { "m.in_reply_to": { event_id: id } } }); + const { made, hs } = await deliver([ + withContent("$n", META, { msgtype: "m.notice", body: "n", ...to("$noticed") }), + withContent("$e", META, { body: "* e", "m.relates_to": { rel_type: "m.replace", event_id: "$orig", "m.in_reply_to": { event_id: "$edited" } } }), + ]); + assert.deepEqual(facts(made), [["$n", null, "notice"], ["$e", null, "edit"]]); + assert.equal(hs.seen.filter(r => r.path.includes("/event/")).length, 0); +}); + +test("addressing facts: a target that failed is not refetched within a minute, and is after it", async () => { + let t = 1_000_000; + const rel = { "m.relates_to": { "m.in_reply_to": { event_id: "$gone" } } }; + const round = id => joined({ [ROOM]: { timeline: { events: [withContent(id, META, { body: id, ...rel })] } } }); + const script = { null: { next_batch: "b0" }, b0: { next_batch: "b1", ...round("$r1") }, b1: { next_batch: "b2", ...round("$r2") }, b2: { next_batch: "b3", ...round("$r3") } }; + const hs = await fakeHomeserver({ sync: since => script[since] ?? { next_batch: since } }); + const fetched = () => hs.seen.filter(r => r.path.includes("/event/")).length; + try { + const mouth = mouthFor(hs, { now: () => t }); + await mouth.syncOnce(); await mouth.syncOnce(); + t += 59_000; await mouth.syncOnce(); + assert.equal(fetched(), 1, "inside the minute: not refetched"); + t += 2_000; await mouth.syncOnce(); + assert.equal(fetched(), 2, "past the minute: read again"); + } finally { await hs.close(); } +}); + +// The room tail (docs/bridge.md, "Room tail"): her rooms' recent talk past her ack cursor, bounded, as data on the v3 answer. +const OPERATOR = "!operator:gamecult.test"; +const lines = (room, n, from = 0, sender = META) => Array.from({ length: n }, (_, i) => message(`$${room.slice(1, 4)}${from + i}`, sender, `line ${from + i}`, 10 + from + i)); +async function tailed({ preload, direct, member, events = [], act = async () => {} } = {}) { + const hs = await fakeHomeserver({ preload, direct, member, sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined(events.length ? { [ROOM]: { timeline: { events } } } : {}) } }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + await act(mouth); + const read = await mouth.tail(); + return { tail: read.lines, gaps: read.gaps, rooms: read.rooms, mouth, hs }; + } finally { await hs.close(); } +} + +test("room tail: 600 lines in a room keep the newest 20, oldest first", async () => { + const { tail } = await tailed({ preload: { [ROOM]: lines(ROOM, 600) } }); + assert.equal(tail.length, 20); + assert.deepEqual(tail.map(l => l.text), Array.from({ length: 20 }, (_, i) => `line ${580 + i}`)); + assert.deepEqual(Object.keys(tail[0]).sort(), ["atUnixMs", "basis", "eventId", "form", "roomAlias", "sender", "text"]); + assert.equal(tail[0].roomAlias, `#swarm:${HS_NAME}`); + assert.equal(tail[0].sender, META); + assert.equal(tail[0].form, "message"); + assert.equal(tail[0].basis, null, "unproven, not 'not for her'"); +}); + +test("room tail: a quiet room, with nothing delivered, still returns its recent lines", async () => { + const { tail } = await tailed({ preload: { [OPERATOR]: lines(OPERATOR, 3) } }); + assert.deepEqual(tail.map(l => l.text), ["line 0", "line 1", "line 2"]); + assert.ok(tail.every(l => l.roomAlias === `#operator:${HS_NAME}`)); +}); + +test("room tail: a room she is not in the speaking set of is absent, and a DM she holds is present with basis direct", async () => { + const STRANGER = "!stranger:gamecult.test", DM = "!mydm:gamecult.test"; + const { tail } = await tailed({ + preload: { [STRANGER]: lines(STRANGER, 2), [DM]: lines(DM, 2), [ROOM]: lines(ROOM, 1) }, + member: [...Object.values(ROOMS), STRANGER, DM], + direct: { [META]: [DM] }, + }); + assert.deepEqual([...new Set(tail.map(l => l.roomAlias))].sort(), [DM, `#swarm:${HS_NAME}`]); + assert.ok(tail.filter(l => l.roomAlias === DM).every(l => l.basis === "direct")); + assert.ok(!tail.some(l => l.eventId.startsWith("$str"))); +}); + +test("room tail: about 60 lines over all rooms, the newest, whatever the room count", async () => { + const dms = ["!d1:gamecult.test", "!d2:gamecult.test", "!d3:gamecult.test", "!d4:gamecult.test"]; + const preload = { [ROOM]: lines(ROOM, 30, 0), [OPERATOR]: lines(OPERATOR, 30, 30) }; + dms.forEach((dm, i) => { preload[dm] = lines(dm, 30, 60 + 30 * i); }); + const { tail } = await tailed({ preload, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] }); + assert.equal(tail.length, 60); + assert.deepEqual(tail.map(l => l.atUnixMs), tail.map(l => l.atUnixMs).sort((a, b) => a - b), "oldest first"); + assert.equal(Math.min(...tail.map(l => l.atUnixMs)), 110, "the oldest kept is the 60th newest of the 120 offered"); +}); + +test("room tail: acking a delivery does not hide it, so an unaddressed line she has acked is still found, and mention basis rides on the line", async () => { + const events = [ + withContent("$a", META, { body: "first" }, 3000), + withContent("$b", META, { body: "tag", "m.mentions": { user_ids: [SELF] } }, 3001), + ]; + const hs = await fakeHomeserver({ sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) } }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + assert.deepEqual((await mouth.tail()).lines.map(l => [l.eventId, l.basis]), [["$a", null], ["$b", "tag"]]); + await mouth.ack(made[1].seq); + assert.equal(mouth.pendingAfter(0).length, 0); + assert.deepEqual((await mouth.tail()).lines.map(l => l.eventId), ["$a", "$b"], "acked lines are the ones no delivery will bring again"); + } finally { await hs.close(); } +}); + +test("room tail: a room that never answers is absent after the timeout and does not hold the others", async () => { + const hs = await fakeHomeserver({ preload: { [OPERATOR]: lines(OPERATOR, 2) }, sync: since => ({ next_batch: since ?? "b0" }) }); + const hang = (url, init) => String(url).includes(encodeURIComponent(ROOM)) && String(url).includes("/messages") ? new Promise(() => {}) : hs.fetch(url, init); + try { + const mouth = mouthFor(hs, { fetch: hang, sendTimeoutMs: 50 }); + await mouth.syncOnce(); + const read = await mouth.tail(); + assert.deepEqual([...new Set(read.lines.map(l => l.roomAlias))], [`#operator:${HS_NAME}`]); + assert.deepEqual(read.gaps, [`#swarm:${HS_NAME}`], "the room that timed out is a gap; the one that answered is not"); + } finally { await hs.close(); } +}); + +test("room tail: a mouth that has not started reads no room", async () => { + const hs = await fakeHomeserver({ preload: { [ROOM]: lines(ROOM, 2) }, sync: since => ({ next_batch: since ?? "b0" }) }); + try { + assert.deepEqual(await mouthFor(hs).tail(), { lines: [], gaps: Object.keys(ROOMS), rooms: Object.keys(ROOMS) }, "no room is known before start, so every configured room is a gap"); + assert.equal(hs.seen.filter(r => r.path.includes("/messages")).length, 0); + } finally { await hs.close(); } +}); + +test("room tail: a body is clipped like a context line, and an unreadable room is absent without silencing the others", async () => { + const { tail } = await tailed({ preload: { [ROOM]: [message("$big", META, "x".repeat(5000), 10)] } }); + assert.equal(Buffer.byteLength(tail[0].text), docs.limits.contextBytes); + const hs = await fakeHomeserver({ failContext: true, sync: since => ({ next_batch: since ?? "b0" }) }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const read = await mouth.tail(); + assert.deepEqual(read.lines, []); + assert.deepEqual(read.gaps, Object.keys(ROOMS).filter(a => read.rooms.includes(a)), "an unreadable room is a gap, not quiet"); + assert.ok(read.gaps.length > 0); + } finally { await hs.close(); } +}); + +test("room tail: only the v3 answer carries it; the v2 answer a v1 request gets is unchanged", async () => { + const { tail, gaps, rooms } = await tailed({ preload: { [ROOM]: lines(ROOM, 2) } }); + const pack = { encode: v => Buffer.from(JSON.stringify(v)) }; + assert.deepEqual(docs.parsePendingV3(pageOf("eureka", [], pack, () => {}, true, { lines: tail, gaps, rooms })).tail.map(l => l.text), ["line 0", "line 1"]); + const v2 = pageOf("eureka", [], pack, () => {}, false, { lines: tail, gaps: rooms, rooms }); + assert.equal(v2.length, 2); + assert.deepEqual(docs.parsePending(v2), { persona: "eureka", deliveries: [] }); + assert.throws(() => docs.parsePendingV3(["eureka", []]), /4-field/); +}); + +test("room tail: the reply byte budget trims the oldest tail lines, never a delivery", async () => { + const pack = { encode: v => Buffer.from(JSON.stringify(v)) }; + const big = Array.from({ length: 60 }, (_, i) => ({ roomAlias: "#r:x", eventId: `$${i}`, atUnixMs: i, sender: "@a:x", form: "message", basis: null, text: "y".repeat(5000) })); + const kept = docs.parsePendingV3(pageOf("eureka", [], pack, () => {}, true, { lines: big, gaps: [], rooms: ["#r:x"] })).tail; + assert.ok(kept.length > 0 && kept.length < 60); + assert.equal(kept.at(-1).eventId, "$59", "the newest survive"); +}); + +// tail_gaps (docs/bridge.md, "Gaps in the tail"): the rooms whose lines are not all in the tail, so a reader says "unknown", not "quiet". +// Each case goes through the composition the RUDP endpoint serves: mouth.tail() into pageOf into the strict v3 parser. +const replied = (read, pack = { encode: v => Buffer.from(JSON.stringify(v)) }) => docs.parsePendingV3(pageOf("eureka", [], pack, () => {}, true, read)); +const SWARM_ALIAS = `#swarm:${HS_NAME}`, OPERATOR_ALIAS = `#operator:${HS_NAME}`; + +test("tail gaps: a room whose read times out is a gap; the room that answered, quiet or not, is not", async () => { + const hs = await fakeHomeserver({ preload: { [OPERATOR]: lines(OPERATOR, 2) }, sync: since => ({ next_batch: since ?? "b0" }) }); + const hang = (url, init) => String(url).includes(encodeURIComponent(ROOM)) && String(url).includes("/messages") ? new Promise(() => {}) : hs.fetch(url, init); + try { + const mouth = mouthFor(hs, { fetch: hang, sendTimeoutMs: 50 }); + await mouth.syncOnce(); + const got = replied(await mouth.tail()); + assert.deepEqual(got.tailGaps, [SWARM_ALIAS]); + assert.deepEqual(got.tail.map(l => l.roomAlias), [OPERATOR_ALIAS, OPERATOR_ALIAS]); + } finally { await hs.close(); } +}); + +test("tail gaps: a failed read is a gap, a quiet room (no lines) and a normal room (a few lines) are not, and exactly the per-room cap is not a trim", async () => { + const failing = await fakeHomeserver({ failContext: true, sync: since => ({ next_batch: since ?? "b0" }) }); + try { + const mouth = mouthFor(failing); + await mouth.syncOnce(); + assert.deepEqual(replied(await mouth.tail()).tailGaps, [SWARM_ALIAS, OPERATOR_ALIAS], "every read failed: both rooms unknown, in room order"); + } finally { await failing.close(); } + + const { gaps } = await tailed({ preload: { [ROOM]: lines(ROOM, docs.limits.tailPerRoom) } }); + assert.deepEqual(gaps, [], "20 lines in one room and none in the other: nothing was left out"); + const some = await tailed({ preload: { [ROOM]: lines(ROOM, 3), [OPERATOR]: [] } }); + assert.deepEqual(replied({ lines: some.tail, gaps: some.gaps, rooms: some.rooms }).tailGaps, []); +}); + +test("tail gaps: a room with more lines than the per-room cap is a gap, once, and only that room", async () => { + const { tail, gaps } = await tailed({ preload: { [ROOM]: lines(ROOM, docs.limits.tailPerRoom + 1), [OPERATOR]: lines(OPERATOR, 3) } }); + assert.equal(tail.filter(l => l.roomAlias === SWARM_ALIAS).length, docs.limits.tailPerRoom); + assert.deepEqual(gaps, [SWARM_ALIAS]); +}); + +test("tail gaps: the total cap names the rooms it dropped lines from, in room order, never a room whose lines all fit", async () => { + const dms = ["!d1:gamecult.test", "!d2:gamecult.test", "!d3:gamecult.test"]; + const preload = { [ROOM]: lines(ROOM, 25, 100), [OPERATOR]: lines(OPERATOR, 20, 0) }; + dms.forEach((dm, i) => { preload[dm] = lines(dm, 20, 200 + 20 * i); }); + const { tail, gaps } = await tailed({ preload, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] }); + assert.equal(tail.length, 60); + assert.deepEqual(gaps, [SWARM_ALIAS, OPERATOR_ALIAS], "swarm is gapped by both caps and named once; the three DMs kept all 20 lines"); +}); + +test("tail gaps: the reply byte budget and a line that will not encode gap their rooms, and only those", () => { + const pack = { encode: v => Buffer.from(JSON.stringify(v)) }; + const mk = (room, i, extra = {}) => ({ roomAlias: room, eventId: `$${room}${i}`, atUnixMs: i, sender: "@a:x", form: "message", basis: null, text: "y".repeat(5000), ...extra }); + const old = Array.from({ length: 30 }, (_, i) => mk("#old:x", i)), fresh = Array.from({ length: 30 }, (_, i) => mk("#new:x", 100 + i)); + const got = replied({ lines: [...old, ...fresh], gaps: [], rooms: ["#old:x", "#new:x", "#empty:x"] }, pack); + assert.ok(got.tail.length < 60); + assert.deepEqual(got.tailGaps, ["#old:x"], "the oldest lines were trimmed; the newer room and the quiet room are whole"); + const bad = replied({ lines: [mk("#a:x", 1), mk("#b:x", 2, { sender: 123 })], gaps: [], rooms: ["#a:x", "#b:x"] }, pack); + assert.deepEqual(bad.tailGaps, ["#b:x"]); +}); + +test("tail gaps: the parser refuses a repeated room, and a pre-gap decoder is stopped by arity, as the tail was", () => { + assert.throws(() => docs.parsePendingV3(["eureka", [], [], ["#a:x", "#a:x"]]), /repeats/); + assert.deepEqual(docs.parsePendingV3(["eureka", [], [], ["#a:x", "#b:x"]]).tailGaps, ["#a:x", "#b:x"]); + assert.throws(() => docs.parsePendingV3(["eureka", [], []]), /4-field/); + assert.throws(() => docs.parsePendingV3(["eureka", [], [], "#a:x"]), /list/); +}); + +test("tail gaps: a line the mouth drops as malformed (no event id) gaps its room; a clean room beside it does not", async () => { + const { tail, gaps } = await tailed({ preload: { [ROOM]: [message("", META, "no id", 10), message("$ok", META, "fine", 11)], [OPERATOR]: lines(OPERATOR, 1) } }); + assert.deepEqual(tail.map(l => l.eventId).sort(), ["$ok", "$ope0"].sort()); + assert.deepEqual(gaps, [SWARM_ALIAS]); +}); + +const edit = (id, ts) => ({ type: "m.room.message", event_id: id, sender: META, origin_server_ts: ts, content: { msgtype: "m.text", body: "* edited", "m.relates_to": { rel_type: "m.replace", event_id: "$x" } } }); +const threaded = (id, ts) => ({ type: "m.room.message", event_id: id, sender: META, origin_server_ts: ts, content: { msgtype: "m.text", body: "in thread", "m.relates_to": { rel_type: "m.thread", event_id: "$root" } } }); +const noise = (n, from) => Array.from({ length: n }, (_, i) => (i % 2 ? threaded : edit)(`$n${from + i}`, 1000 + from + i)); + +test("tail gaps: a full read window hides older real lines behind edits and thread replies, so the room is a gap; a short chunk is exhausted history, not a gap", async () => { + const hidden = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(120, 0)] } }); + assert.deepEqual(hidden.tail, [], "the five real lines lie beyond the 100-event window"); + assert.deepEqual(hidden.gaps, [SWARM_ALIAS], "so the room is unknown, not quiet"); + const whole = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(60, 0)] } }); + assert.equal(whole.tail.length, 5); + assert.deepEqual(whole.gaps, [], "a chunk shorter than the window reached the room's start"); + const at99 = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(94, 0)] } }); + assert.deepEqual(at99.gaps, [], "99 raw events: one short of the window, history exhausted"); + const at100 = await tailed({ preload: { [ROOM]: [...lines(ROOM, 5), ...noise(95, 0)] } }); + assert.deepEqual(at100.gaps, [SWARM_ALIAS], "exactly 100 raw events fill the window, so older history cannot be ruled out"); +}); + +test("tail gaps: the total cap boundary: exactly 60 lines over all rooms is no gap, a 61st names the room that lost its oldest line", async () => { + const dms = ["!d1:gamecult.test", "!d2:gamecult.test"]; + const rooms60 = { [ROOM]: lines(ROOM, 20, 0), [OPERATOR]: lines(OPERATOR, 20, 20), [dms[0]]: lines(dms[0], 20, 40) }; + const at60 = await tailed({ preload: rooms60, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] }); + assert.equal(at60.tail.length, 60); + assert.deepEqual(at60.gaps, []); + const at61 = await tailed({ preload: { ...rooms60, [dms[1]]: lines(dms[1], 1, 60) }, direct: { [META]: dms }, member: [...Object.values(ROOMS), ...dms] }); + assert.equal(at61.tail.length, 60); + assert.deepEqual(at61.gaps, [SWARM_ALIAS]); +}); + +test("tail gaps: a byte-budget cut is contiguous at the oldest end, so an older line that would still fit is cut and its room gapped", () => { + const pack = { encode: v => Buffer.from(JSON.stringify(v)) }; + const mk = (room, i, text) => ({ roomAlias: room, eventId: `$${room}${i}`, atUnixMs: i, sender: "@a:x", form: "message", basis: null, text }); + const tiny = mk("#tiny:x", 0, "t"), big = Array.from({ length: 70 }, (_, i) => mk("#big:x", 1 + i, "y".repeat(5000))); + const got = replied({ lines: [tiny, ...big], gaps: [], rooms: ["#tiny:x", "#big:x"] }, pack); + assert.ok(!got.tail.some(l => l.roomAlias === "#tiny:x"), "nothing older than the cut is kept"); + assert.deepEqual(got.tailGaps, ["#tiny:x", "#big:x"]); +}); + +test("tail gaps: the read timer is cleared when the read answers, and a mouth that has not started names its configured rooms for an endpoint that cannot read", async () => { + const delay = 4321, made = new Set(), cleared = new Set(); + const realSet = globalThis.setTimeout, realClear = globalThis.clearTimeout; + globalThis.setTimeout = (fn, ms, ...rest) => { const t = realSet(fn, ms, ...rest); if (ms === delay) made.add(t); return t; }; + globalThis.clearTimeout = t => { cleared.add(t); return realClear(t); }; + const hs = await fakeHomeserver({ preload: { [ROOM]: lines(ROOM, 2), [OPERATOR]: lines(OPERATOR, 2) }, sync: since => ({ next_batch: since ?? "b0" }) }); + try { + const mouth = mouthFor(hs, { sendTimeoutMs: delay }); + assert.deepEqual(mouth.speakableRooms(), Object.keys(ROOMS), "before start: the configured rooms"); + await mouth.syncOnce(); + assert.deepEqual(mouth.speakableRooms(), Object.keys(ROOMS), "after start: the rooms she can speak in"); + await mouth.tail(); + assert.ok(made.size >= 2); + 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 = "é".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/tests/shared-value-stores.test.mjs b/tests/shared-value-stores.test.mjs new file mode 100644 index 0000000..092f15a --- /dev/null +++ b/tests/shared-value-stores.test.mjs @@ -0,0 +1,86 @@ +// Six document types once declared `indexes:` on grouping fields (status, repo, agent, topic...) that many rows share. +// CultLib's `indexes:` means unique, so each store must still write, close and reopen with rows that share those values. +// Real CLIs and store openers against temp files; CultLib comes from tests/cultlib-root.mjs. +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { cultLibRoot } from "./cultlib-root.mjs"; +import { openCrossingReceiptStore } from "../tools/bifrost-crossing-documents.mjs"; +import { buildAuthorizedRelease, openRepositoryReleaseAuthorityStore } from "../tools/bifrost-repository-release-authority.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const dir = mkdtempSync(resolve(tmpdir(), "bifrost-shared-")); +test.after(() => rmSync(dir, { recursive: true, force: true })); + +const env = { + ...process.env, + VOIDBOT_CULTLIB_ROOT: cultLibRoot, + BIFROST_SKIP_LOCAL_ENV: "true", + BIFROST_ROOT_VERSE: "test-verse", + BIFROST_MACHINE_ID: "test-machine", + BIFROST_CROSSING_RECEIPT_STORE: resolve(dir, "receipts.cc"), +}; +for (const name of ["BIFROST_BRIDGE_BASE_URL", "BIFROST_BRIDGE_TOKEN", "BIFROST_ALLOW_UNRECEIPTED_ACTIVITY"]) delete env[name]; + +function cli(tool, args) { + const run = spawnSync(process.execPath, [resolve(root, "tools", tool), ...args], { env, encoding: "utf8", cwd: root }); + assert.equal(run.status, 0, `${tool} ${args[0]}\n${run.stderr}`); + return run.stdout; +} + +test("agent-transport store holds requests that share repo, agent and status", () => { + const store = resolve(dir, "agent-transport.cc"); + for (const id of ["req-1", "req-2", "req-3"]) { + cli("agent-transport.mjs", ["enqueue", "--store", store, "--id", id, "--repo", "Bifrost", "--agent", "heimdall", "--title", id, "--request", "body", "--allow-unmirrored", "true"]); + } + const listed = JSON.parse(cli("agent-transport.mjs", ["list", "--store", store])); + assert.deepEqual(listed.map((request) => request.id).sort(), ["req-1", "req-2", "req-3"]); +}); + +test("governance-threads store holds topics and comments that share their grouping fields", () => { + const store = resolve(dir, "governance.cc"); + for (const id of ["topic-1", "topic-2"]) { + cli("governance-threads.mjs", ["open", "--store", store, "--id", id, "--title", id, "--repo", "Bifrost", "--agent", "heimdall", "--summary", "s", "--allow-unmirrored", "true"]); + } + for (const id of ["c-1", "c-2"]) { + cli("governance-threads.mjs", ["comment", "--store", store, "--topic", "topic-1", "--id", id, "--author", "heimdall", "--stance", "support", "--body", "same", "--allow-unmirrored", "true"]); + } + const topics = JSON.parse(cli("governance-threads.mjs", ["list", "--store", store])); + assert.deepEqual(topics.map((topic) => topic.id).sort(), ["topic-1", "topic-2"]); + const shown = JSON.parse(cli("governance-threads.mjs", ["show", "--store", store, "--topic", "topic-1"])); + assert.equal(JSON.stringify(shown).match(/"id": ?"c-\d"/g)?.length ?? 0, 2); +}); + +test("crossing receipt store reopens with receipts that share status, kind and command", async () => { + const receipts = await openCrossingReceiptStore(env.BIFROST_CROSSING_RECEIPT_STORE); + const before = receipts.getAll().length; + assert.ok(before >= 5, "the CLI runs above leave completed agent_transport.request receipts"); + const reopened = await openCrossingReceiptStore(env.BIFROST_CROSSING_RECEIPT_STORE); + assert.equal(reopened.getAll().length, before); + assert.ok(new Set(reopened.getAll().map((receipt) => `${receipt.status}/${receipt.crossingKind}`)).size < before); +}); + +test("release authority store reopens with authorities that share repository, ref and status", async () => { + const path = resolve(dir, "release-authority.cc"); + const authority = (commitSha) => buildAuthorizedRelease({ + repositoryFullName: "GameCult/Bifrost", upstreamRef: "refs/heads/main", commitSha, + commandId: `cmd-${commitSha[0]}`, crossingReceiptId: `crossing-${commitSha[0]}`, policyDecisionId: "policy", authorityReference: "ref", + actorIdentity: "heimdall", sourceKind: "test", sourceId: "src", epiphanyRunId: "run", epiphanyLaneId: "lane", epiphanyAgentIdentity: "heimdall", + externalReceiptUrl: "https://example.test/r", externalReceiptId: "r", authorizedAt: "2026-11-11T00:00:00.000Z", + }); + const store = await openRepositoryReleaseAuthorityStore(path); + await store.put(authority("a".repeat(40))); + await store.put(authority("b".repeat(40))); + const reopened = await openRepositoryReleaseAuthorityStore(path); + assert.equal(reopened.getAll().length, 2); +}); + +test("provider advertisement store exports and re-exports beside other documents", () => { + const out = resolve(dir, "provider.cc"); + cli("provider-advertisement.mjs", ["export", "--out", out]); + const second = JSON.parse(cli("provider-advertisement.mjs", ["export", "--out", out])); + assert.equal(second.ok, true); +}); diff --git a/tests/watchdog-notice.test.mjs b/tests/watchdog-notice.test.mjs index 21d27aa..954c051 100644 --- a/tests/watchdog-notice.test.mjs +++ b/tests/watchdog-notice.test.mjs @@ -1,3 +1,4 @@ +import { cultLibRoot } from "./cultlib-root.mjs"; import test from "node:test"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; @@ -26,8 +27,7 @@ const T_OPEN = "2023-11-14T22:13:20.000Z"; const T_CLOSE = "2023-11-14T22:15:00.000Z"; const CLOSED_TEXT = `Idunn incident closed (recovered): continuity-exhausted on \`closed-target\`, opened ${T_OPEN}, closed ${T_CLOSE}`; -// CultLib is the sibling ../CultLib, like the other tests. -const cult = resolve(import.meta.dirname, "..", "..", "CultLib"); +const cult = cultLibRoot; const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json"))("@gamecult/cultcache-ts"); const msgpack = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json"))("@msgpack/msgpack"); diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 42c7337..b187b81 100644 --- a/tools/agent-transport.mjs +++ b/tools/agent-transport.mjs @@ -21,13 +21,15 @@ const defaultPersonaName = "Bifrost"; const defaultPersonaAvatarUrl = "https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png"; +const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib")); + const cultCachePackagePath = resolveFirstExisting("CultCache TypeScript runtime", [ - resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"), resolve(projectsRoot, "CultCacheTS", "package.json"), resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), ]); const cultNetPackagePath = resolveFirstExisting("CultNet TypeScript runtime", [ - resolve(projectsRoot, "CultLib", "packages", "cultnet-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultnet-ts", "package.json"), resolve(projectsRoot, "CultNetTS", "package.json"), ]); const cultCacheRequire = createRequire(cultCachePackagePath); @@ -68,11 +70,6 @@ const updateRequestDefinition = defineDocumentType({ parse: parseUpdateRequest, }, name: "id", - indexes: { - targetRepoName: "targetRepoName", - targetAgentIdentity: "targetAgentIdentity", - status: "status", - }, members: [ { slot: 1, memberName: "id", typeName: "string", isName: true }, { slot: 2, memberName: "targetRepoName", typeName: "string", indexAlias: "targetRepoName" }, 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/bifrost-crossing-documents.mjs b/tools/bifrost-crossing-documents.mjs index b7d60ac..a05c27f 100644 --- a/tools/bifrost-crossing-documents.mjs +++ b/tools/bifrost-crossing-documents.mjs @@ -27,11 +27,6 @@ export function crossingReceiptDefinition(defineDocumentTypeInput) { contentHash: crossingReceiptSchemaId, global: false, name: "receiptId", - indexes: { - commandId: "commandId", - crossingKind: "crossingKind", - status: "status", - }, schema: { parse: parseCrossingReceipt }, members: [ { slot: 1, memberName: "receiptId", typeName: "string", isName: true }, diff --git a/tools/bifrost-repository-release-authority.mjs b/tools/bifrost-repository-release-authority.mjs index f1bf4a2..c8d0c02 100644 --- a/tools/bifrost-repository-release-authority.mjs +++ b/tools/bifrost-repository-release-authority.mjs @@ -24,7 +24,6 @@ export function repositoryReleaseAuthorityDefinition(defineDocumentTypeInput) { contentHash: repositoryReleaseAuthoritySchemaId, global: false, name: "authorityId", - indexes: { repositoryFullName: "repositoryFullName", upstreamRef: "upstreamRef", commitSha: "commitSha", status: "status" }, schema: { parse: parseRepositoryReleaseAuthority }, members: [ { slot: 1, memberName: "authorityId", typeName: "string", isName: true }, 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/governance-threads.mjs b/tools/governance-threads.mjs index 832e5f7..7359ceb 100644 --- a/tools/governance-threads.mjs +++ b/tools/governance-threads.mjs @@ -23,8 +23,10 @@ const defaultPersonaName = "Bifrost"; const defaultPersonaAvatarUrl = "https://raw.githubusercontent.com/GameCult/Bifrost/main/src/Bifrost.Web/wwwroot/img/bifrost-profile.png"; +const cultLibRoot = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(projectsRoot, "CultLib")); + const cultCachePackagePath = resolveFirstExisting("CultCache TypeScript runtime", [ - resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"), + resolve(cultLibRoot, "packages", "cultcache-ts", "package.json"), resolve(projectsRoot, "CultCacheTS", "package.json"), resolve(projectsRoot, "CultCacheTS", "node_modules", "cultcache-ts", "package.json"), ]); @@ -59,11 +61,6 @@ const topicDefinition = defineDocumentType({ schemaVersion: "bifrost.governance.topic.v0", schema: { parse: parseTopic }, name: "id", - indexes: { - jurisdictionRepoName: "jurisdictionRepoName", - jurisdictionAgentIdentity: "jurisdictionAgentIdentity", - status: "status", - }, members: [ { slot: 1, memberName: "id", typeName: "string", isName: true }, { slot: 2, memberName: "title", typeName: "string" }, @@ -93,11 +90,6 @@ const commentDefinition = defineDocumentType({ schemaVersion: "bifrost.governance.topic_comment.v0", schema: { parse: parseComment }, name: "id", - indexes: { - topicId: "topicId", - authorId: "authorId", - stance: "stance", - }, members: [ { slot: 1, memberName: "id", typeName: "string", isName: true }, { slot: 2, memberName: "topicId", typeName: "string", indexAlias: "topicId" }, 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 f20cad4..afdc7f3 100644 --- a/tools/persona-chat-documents.mjs +++ b/tools/persona-chat-documents.mjs @@ -9,6 +9,10 @@ import { IMAGE_PATH, MAX_BYTES, MIMES, extensionOf } from "./persona-chat-images export const kinds = Object.freeze({ pendingRequest: "gamecult.persona.chat_pending_request.v1", 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", @@ -16,6 +20,10 @@ export const kinds = Object.freeze({ sendRequestV2: "gamecult.persona.chat_send_request.v2", sendReceiptV2: "gamecult.persona.chat_send_receipt.v2", 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"]); @@ -23,13 +31,26 @@ export const refusals = Object.freeze(["Brake", "PersonaMismatch", "UnknownRoom" // RoomMoved exists only in the v2 receipt: the v1 tuple, a v1 parse and the typed refusal document do not know it. export const refusalsV2 = Object.freeze([...refusals, "RoomMoved"]); +// The typed refusal document also answers a request of a kind this mouth does not serve (no Persona is known then). +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 }); +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) { @@ -45,6 +66,12 @@ export function parsePendingRequest(t) { return { persona: text(persona, "persona"), afterSeq: seq(afterSeq, "after_seq") }; } +// 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). "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 }) { return [text(persona, "persona"), seq(throughSeq, "through_seq")]; } @@ -119,21 +146,79 @@ export function parseSendReceiptV2(t) { // A typed refusal for a request that names a Persona the mouth does not serve. export function refusalTuple({ persona, refusal }) { - if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`); + if (!refusalDocument.includes(refusal)) throw new Error(`unknown refusal ${refusal}`); return [text(persona, "persona"), refusal]; } export function parseRefusal(t) { const [persona, refusal] = tuple(t, 2, "chat_refusal"); - if (!refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`); + if (!refusalDocument.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`); return { persona: text(persona, "persona"), refusal }; } +// A Persona asks her mouth to open a direct room with one person and say a first line there. user_id is a Matrix user id. +export function openDirectRequestTuple({ requestId, persona, userId, text: body }) { + return [text(requestId, "request_id"), text(persona, "persona"), text(userId, "user_id"), String(body)]; +} +export function parseOpenDirectRequest(t) { + const [requestId, persona, userId, body] = tuple(t, 4, "chat_open_direct_request"); + if (typeof body !== "string") throw new Error("chat_open_direct_request text must be a string"); + if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_open_direct_request text is over the byte bound"); + return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), userId: text(userId, "user_id"), text: body }; +} + +function checkDirectReceipt(outcome, roomId, eventId) { + if (!directOutcomes.includes(outcome)) throw new Error(`unknown open-direct outcome ${String(outcome)}`); + const placed = outcome === "Opened" || outcome === "Existing"; + if (placed !== (roomId !== null && eventId !== null) || (!placed && (roomId !== null || eventId !== null))) throw new Error("room_id and event_id are present exactly for Opened and Existing"); +} +export function openDirectReceiptTuple({ requestId, outcome, roomId = null, eventId = null }) { + checkDirectReceipt(outcome, roomId, eventId); + return [text(requestId, "request_id"), outcome, roomId === null ? null : text(roomId, "room_id"), eventId === null ? null : text(eventId, "event_id")]; +} +export function parseOpenDirectReceipt(t) { + const [requestId, outcome, roomId, eventId] = tuple(t, 4, "chat_open_direct_receipt"); + checkDirectReceipt(outcome, roomId, eventId); + 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), ]; } @@ -156,15 +241,63 @@ function parseImage(t) { imageTuple({ path, mime, size, sha256 }); return { path, mime, size, sha256 }; } +// The v3 delivery: the 12 fields above, then basis and form. +export function deliveryV3Tuple(d) { + return [...deliveryTuple(d), basis(d.basis ?? null), form(d.form ?? "message")]; +} +export function parseDeliveryV3(t) { + const fields = tuple(t, 14, "delivery_v3"); + return { ...parseDelivery(fields.slice(0, 12)), basis: basis(fields[12]), form: form(fields[13]) }; +} +function basis(v) { + if (v !== null && !bases.includes(v)) throw new Error(`unknown delivery basis ${String(v)}`); + return v; +} +function form(v) { + if (!forms.includes(v)) throw new Error(`unknown delivery form ${String(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), }; } @@ -178,6 +311,44 @@ export function parsePending(t) { return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDelivery) }; } +// 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"), 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: 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 +// tail_gaps: the rooms (alias, or room id for a DM) whose lines are not all in the tail, each once. Absent or empty: every room was read fully. +export function pendingV3Tuple({ persona, deliveries, tail = [], tailGaps = [] }) { + if (tail.length > limits.tailTotal) throw new Error("chat_pending tail is over the line bound"); + return [text(persona, "persona"), deliveries.map(deliveryV3Tuple), tail.map(tailLineTuple), tailGaps.map(room => text(room, "tail_gap"))]; +} +export function parsePendingV3(t) { + const [persona, deliveries, tail, tailGaps] = tuple(t, 4, "chat_pending_v3"); + if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list"); + if (!Array.isArray(tail)) throw new Error("chat_pending tail must be a list"); + if (!Array.isArray(tailGaps)) throw new Error("chat_pending tail_gaps must be a list"); + const gaps = tailGaps.map(room => text(room, "tail_gap")); + if (new Set(gaps).size !== gaps.length) throw new Error("chat_pending tail_gaps repeats a room"); + 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 { @@ -190,10 +361,11 @@ export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgen }; } +// allowedKinds null: any kind passes (the caller answers a kind it does not serve). export function checkEnvelope(message, allowedKinds) { const d = message?.document; if (message?.schemaVersion !== "cultnet.document_put_raw.v0" || !d) throw new Error("not a document_put_raw message"); - if (!allowedKinds.includes(d.schemaId)) throw new Error(`document kind ${String(d.schemaId)} is not accepted here`); + if (allowedKinds !== null && !allowedKinds.includes(d.schemaId)) throw new Error(`document kind ${String(d.schemaId)} is not accepted here`); if (d.payloadEncoding !== "messagepack") throw new Error("chat documents are MessagePack"); if (!Array.isArray(d.tags) || d.tags.length !== 1 || d.tags[0] !== "cultnet.transport.rudp.v0") throw new Error("chat document tags substituted authority"); return d; @@ -203,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 658c728..0964830 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -5,7 +5,9 @@ // from another sender becomes a delivery kept in memory, carrying its sender id, // 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. Reading wakes nothing: the heartbeat polls +// 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, 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 // input: the mouth posts m.read at that event id only, for an event it delivered to her, never behind one @@ -21,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. @@ -58,8 +65,25 @@ const SLUG = /^[A-Za-z0-9_-]{1,64}$/; const SEND_TIMEOUT_MS = 10000; // the head read and the PUT of one gated send const PRESENCE_GRACE_MS = 5 * 60 * 1000; // a turn's last sign of life keeps her online this long const DELIVERED_INDEX = 4000; // delivered event ids remembered so a read report can be checked against them +const DM_SCAN = 200; // joined rooms read when looking for an existing DM with a person +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. +const formOf = content => content?.["m.relates_to"]?.rel_type === "m.replace" ? "edit" : content?.msgtype === "m.notice" ? "notice" : "message"; + // A message line: a text event with a sender, not an edit. const isLine = e => e?.type === "m.room.message" && typeof e.content?.body === "string" && typeof e.sender === "string" && e.sender !== "" && e.content?.["m.relates_to"]?.rel_type !== "m.replace"; // Whether `event` moves the room under a Persona whose account is `selfId`: someone else's line that is @@ -112,8 +136,11 @@ 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 }) { if (!persona || !homeserver || !token || !rooms?.length) throw new Error("persona, homeserver, rooms and token are required"); const say = message => log(redact(String(message), token)); @@ -132,12 +159,17 @@ export function createMouth({ let started = false; let lastPresence = null; // 'online' | 'unavailable' | 'offline' | null: command-only, suppresses repeat presence PUTs let activeUntil = 0; // her last sign of life in a turn, plus the grace + const tailReads = new Map(); // room id -> { at, lines }: a room's tail read, kept TAIL_TTL_MS or until a sync brings the room news const delivered = new Map(); // event id -> { roomId, roomAlias, seq }: what the mouth handed her, newest last, bounded const readThrough = new Map(); // room id -> { seq, eventId } of the last receipt that landed const readTails = new Map(); // room id -> the read report in flight: reports for one room run one at a time const directContent = {}; // the account's m.direct content: user id -> room ids const invites = new Map(); // room id -> { inviter, direct }: invitations not yet answered 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) { let response; @@ -151,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(); } @@ -197,6 +229,8 @@ export function createMouth({ } } const unjoined = () => joinedAliases.size < rooms.length; + // The rooms this Persona may speak in: a configured room she has joined, or one of her DMs. One rule for send and for the tail. + const speakableRoomId = alias => joinedAliases.has(alias) ? roomIdByAlias.get(alias) : directRooms.has(alias) ? alias : null; // Join the configured rooms the account is not in, only while the brake is released. // One room failing is logged and retried on the next round; it never stops the others. @@ -238,11 +272,14 @@ export function createMouth({ const lineOf = e => ({ sender: e.sender, text: bodyText(e.content, docs.limits.contextBytes), atUnixMs: Number.isSafeInteger(e.origin_server_ts) && e.origin_server_ts >= 0 ? e.origin_server_ts : 0 }); // The room's recent messages outside threads, oldest first. - async function roomEvents(roomId) { + // `truncated`: the homeserver filled the whole window, so older history may exist that this read never saw. + async function roomRead(roomId) { const filter = encodeURIComponent(JSON.stringify({ types: ["m.room.message"] })); const reply = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=${FETCH_LIMIT}&filter=${filter}`); - return (reply.chunk ?? []).filter(e => isLine(e) && threadRootOf(e) === null).reverse(); + const chunk = reply.chunk ?? []; + return { events: chunk.filter(e => isLine(e) && threadRootOf(e) === null).reverse(), truncated: chunk.length >= FETCH_LIMIT }; } + const roomEvents = async roomId => (await roomRead(roomId)).events; // One thread's messages, root first. async function threadEvents(roomId, rootId) { const room = encodeURIComponent(roomId); @@ -268,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; @@ -277,6 +315,202 @@ 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 + // when none holds. Her names, her projects' names and the sender's role are the heartbeat's to match (Huginn address.rs), never the + // mouth's. A reply target that cannot be read is null and logged: unknown, not "not hers". + async function basisOf(roomId, event, lookup = true) { + if (directRooms.has(roomId)) return "direct"; + const mentions = event.content["m.mentions"]; + if (Array.isArray(mentions?.user_ids) ? mentions.user_ids.includes(selfId) : mentions === undefined && pillFor(event.content.formatted_body)) return "tag"; + if (lookup && formOf(event.content) === "message" && await repliesToMe(roomId, event.content["m.relates_to"])) return "reply"; // an edit or a notice wakes no one: no fetch for it + return mentions?.room === true ? "room" : null; + } + // A pill is an anchor whose href is exactly her matrix.to link; a longer id, an attribute that merely contains one and a quoted reply do not count. + const pillFor = html => typeof html === "string" && [...html.replace(/[\s\S]*?<\/mx-reply>/gi, "").matchAll(/]*?href\s*=\s*(["'])(.*?)\1/gi)].some(([, , href]) => [selfId, encodeURIComponent(selfId)].some(id => href === `https://matrix.to/#/${id}`)); + const authors = new Map(); // `${room id} ${event id}` -> { sender, excerpt, at }: read results, kept TARGET_TTL_MS, bounded; the key holds the room so another room's reply cannot borrow a hit + const TARGET_TTL_MS = 60 * 1000; // how long a read of a target 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) 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) { // 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}`); + } + 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 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 !== "")); + for (const id of targets) if (await authorOf(roomId, id) === selfId) return true; + return false; + } + // The image of one delivered m.image event: { image } (a stored reference) or { refused } (a reason word). With no // store nothing is attempted and nothing is said. The url is the event's own content.url; encrypted events and // anything the checks refuse leave only the text line. @@ -308,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 @@ -330,14 +567,21 @@ export function createMouth({ const shown = event.content?.displayname; if (event.type === "m.room.member" && typeof event.state_key === "string" && typeof shown === "string" && shown !== "") displayNames.set(event.state_key, docs.clip(shown, docs.limits.displayBytes)); } + 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); @@ -346,10 +590,10 @@ export function createMouth({ const mentioned = event.content["m.mentions"]?.user_ids; 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), + 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); } @@ -422,6 +666,57 @@ export function createMouth({ touch(); } + // Her rooms' recent talk, for a reader that was not woken for it: in each room she may speak in, the newest tailPerRoom lines + // whether or not they were delivered or acked (a delivery the wake rule left unaddressed is acked and gone from `pending`; the tail + // is where a beat still finds it, and the reader removes what a delivery already shows), the newest tailTotal over all rooms, + // oldest first. From the same /messages read as a delivery's context, as her account, bodies clipped as context lines are. The + // basis is what Matrix says on the event itself (no reply lookup: nil is unproven). A room that cannot be read in sendTimeoutMs + // is logged and absent, so one stuck room never holds the reply. + // Returns { lines, gaps, rooms }: `rooms` are the rooms read, in read order (configured rooms in join order, then DMs); `gaps` are + // those of them whose lines are not all in `lines` (read failed or timed out, the read window full so older lines may be unseen, a line past a cap, a line dropped as malformed), in + // the same order. A room with nothing to show because it is quiet is not a gap. A mouth that has not started knows no room but the + // configured ones, and all of those are gaps. + async function tail() { + if (!started) return { lines: [], gaps: [...rooms], rooms: [...rooms] }; + const targets = new Set(); + for (const alias of [...joinedAliases, ...directRooms]) { const id = speakableRoomId(alias); if (id) targets.add(id); } + const gapped = new Set(); + const perRoom = await Promise.all([...targets].map(async roomId => { + const alias = aliasByRoomId.get(roomId) ?? roomId; + let read = tailReads.get(roomId); + if (!read || now() - read.at >= TAIL_TTL_MS) { + let timer; + const lines = Promise.race([ + roomRead(roomId), + new Promise((_, fail) => { timer = setTimeout(() => fail(new Error(`no answer in ${sendTimeoutMs} ms`)), sendTimeoutMs); }), + ]).catch(error => { say(`tail for ${roomId} not fetched: ${error.message}`); return null; }).finally(() => clearTimeout(timer)); + read = { at: now(), lines }; + tailReads.set(roomId, read); + } + const got = await read.lines; + if (got === null) { if (tailReads.get(roomId) === read) tailReads.delete(roomId); gapped.add(alias); return []; } + const { events, truncated } = got; + if (truncated || events.length > docs.limits.tailPerRoom) gapped.add(alias); + const lines = []; + for (const e of events.slice(-docs.limits.tailPerRoom)) { + 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) }); + } + return lines; + })); + const newest = perRoom.flat().sort((a, b) => a.atUnixMs - b.atUnixMs); + for (const dropped of newest.slice(0, Math.max(0, newest.length - docs.limits.tailTotal))) gapped.add(dropped.roomAlias); + const order = [...targets].map(id => aliasByRoomId.get(id) ?? id); + return { lines: newest.slice(-docs.limits.tailTotal), gaps: order.filter(a => gapped.has(a)), rooms: order }; + } + // The rooms `tail` would read, in its order: what a reply names as unread when the read itself fails outright. + function speakableRooms() { + const ids = new Set(); + for (const alias of [...joinedAliases, ...directRooms]) { const id = speakableRoomId(alias); if (id) ids.add(id); } + return started ? [...ids].map(id => aliasByRoomId.get(id) ?? id) : [...rooms]; + } + function pendingAfter(afterSeq) { return pending.filter(d => d.seq > afterSeq).map(d => ({ ...d, context: d.context.map(c => ({ ...c })), mentions: [...d.mentions], image: d.image && { ...d.image } })); } @@ -447,7 +742,7 @@ export function createMouth({ if (typeof text !== "string" || text.trim() === "") return refuse("EmptyText"); try { if (!started || unjoined()) await start(); - const roomId = joinedAliases.has(roomAlias) ? roomIdByAlias.get(roomAlias) : directRooms.has(roomAlias) ? roomAlias : null; + const roomId = speakableRoomId(roomAlias); if (!roomId) return refuse("UnknownRoom"); const result = await gate.run(roomId, async () => { if (basis) { @@ -458,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}`); @@ -466,6 +764,225 @@ export function createMouth({ } } + // Every room this account shares with `userId` alone, newest first: { roomId, createdAt, marker, encrypted, theirs }, theirs the + // person's membership ("join" | "invite" | "leave" | "ban" | null when they were never invited). Project rooms are not DMs. A + // room qualifies when its members are exactly the two of us, or m.direct lists it for the person (a created room whose invite failed). + async function directsWith(userId) { + const joined = (await matrix("GET", "/joined_rooms")).joined_rooms ?? []; + const known = new Set(directContent[userId] ?? []); + const candidates = [...known, ...joined.filter(id => !known.has(id) && !aliasByRoomId.has(id))].slice(0, DM_SCAN); + const found = []; + for (const roomId of candidates) { + let events; + try { events = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/state`); } catch (error) { + if (/ 40[34] /.test(error.message)) continue; // a room m.direct still lists that this account has left or forgotten + throw error; + } + const members = events.filter(e => e.type === "m.room.member"); + const mine = members.find(e => e.state_key === selfId); + if (mine?.content?.membership !== "join") continue; + const others = members.filter(e => e.state_key !== selfId); + const theirs = others.find(e => e.state_key === userId); + if (!(known.has(roomId) || (others.length === 1 && theirs))) continue; + const create = events.find(e => e.type === "m.room.create"); + found.push({ + roomId, createdAt: create?.origin_server_ts ?? 0, marker: create?.content?.["org.gamecult.open_request"] ?? null, + encrypted: events.some(e => e.type === "m.room.encryption"), theirs: theirs?.content?.membership ?? null, + }); + } + return found.sort((a, b) => b.createdAt - a.createdAt); + } + + // The first line of an open: the invite is the ask and the room is not end-to-end encrypted, and the line says so. + // Every line into a room this path created carries the notice until one has gone out (a restart repeats it once at worst). + async function putLine(roomId, requestId, text, made) { + return gate.run(roomId, async () => { + if (!(await released())) return null; + const body = made && !noticed.has(roomId) ? `${NOT_E2EE}\n\n${text}` : text; + const sent = (await matrix("PUT", `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(requestId)}`, { msgtype: "m.text", body }, API, AbortSignal.timeout(sendTimeoutMs))).event_id; + if (made) noticed.add(roomId); + return sent; + }); + } + + // A Persona's request for a direct room with one person. One outcome word and, for Opened and Existing, the room and the line's event. + function openDirect(request) { + const key = typeof request.userId === "string" ? request.userId : ""; + const run = () => openDirectNow(request); + const tail = (personTails.get(key) ?? Promise.resolve()).then(run, run); + personTails.set(key, tail); + const done = () => { if (personTails.get(key) === tail) personTails.delete(key); }; + tail.then(done, done); + return tail; + } + async function openDirectNow({ requestId, persona: asked, userId, text }) { + const answer = (outcome, roomId = null, eventId = null) => { say(`open direct ${requestId}: ${outcome}${roomId ? ` ${roomId}` : ""}`); return { requestId, outcome, roomId, eventId }; }; + if (asked !== persona) return answer("PersonaMismatch"); + if (!(await released())) return answer("Brake"); + if (typeof text !== "string" || text.trim() === "") return answer("EmptyText"); + try { + if (!started || unjoined()) await start(); + if (!serverName || typeof userId !== "string" || userId.slice(userId.indexOf(":") + 1) !== serverName) return answer("ForeignUser"); + if (userId === selfId || await isServed(userId)) return answer("NotAPerson"); + try { await matrix("GET", `/profile/${encodeURIComponent(userId)}`); } catch (error) { + if (/ 404 /.test(error.message)) return answer("NoSuchUser"); + throw error; + } + const rooms = await directsWith(userId); + const usable = rooms.filter(r => !r.encrypted); // a line is never sent into an encrypted room + const resumed = usable.find(r => r.marker === requestId); // a repeat of this very request after a crash + const live = resumed ?? usable.find(r => r.theirs === "join"); + if (live) { + const eventId = await putLine(live.roomId, requestId, text, live.marker !== null); + return eventId === null ? answer("Brake") : answer(resumed ? "Opened" : "Existing", live.roomId, eventId); + } + const latest = rooms.find(r => ["invite", "leave", "ban"].includes(r.theirs)); // an invite or a decline stands in an encrypted room too + if (latest?.theirs === "invite") return answer("InvitePending"); + if (latest) return answer("Declined"); // left or banned, and nothing newer: only the person reopens it, by inviting her + let roomId = usable.find(r => r.theirs === null && r.marker !== null)?.roomId ?? null; // made here earlier, the invite never went out + if (roomId === null) { + if (openedOn.get(userId) === dayOf(now())) return answer("DailyLimit"); + if (!(await released())) return answer("Brake"); + ({ room_id: roomId } = await matrix("POST", "/createRoom", { preset: "private_chat", is_direct: true, creation_content: { "m.federate": false, "org.gamecult.open_request": requestId } }, API, AbortSignal.timeout(sendTimeoutMs))); + openedOn.set(userId, dayOf(now())); + directRooms.add(roomId); + directContent[userId] = [...new Set([...(directContent[userId] ?? []), roomId])]; + await matrix("PUT", `/user/${encodeURIComponent(selfId)}/account_data/m.direct`, directContent).catch(error => say(`m.direct not saved: ${error.message}`)); + } + if (!(await released())) return answer("Brake"); + await matrix("POST", `/rooms/${encodeURIComponent(roomId)}/invite`, { user_id: userId, reason: docs.clip(text, 300) }, API, AbortSignal.timeout(sendTimeoutMs)); + const eventId = await putLine(roomId, requestId, text, true); + if (eventId === null) return answer("Brake"); + touch(); setPresence(true).catch(() => {}); + return answer("Opened", roomId, eventId); + } catch (error) { + say(`open direct failed: ${error.message}`); + return answer("SendFailed"); + } + } + + // 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) { @@ -479,7 +996,14 @@ export function createMouth({ } } - return { start, syncOnce, ack, read, pendingAfter, send, run, 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. @@ -524,12 +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. -export function pageOf(persona, deliveries, msgpack, log = () => {}) { +// 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: [] }, v4 = false) { const page = []; let bytes = 0; for (const delivery of deliveries) { let tuple, size; - try { tuple = 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; } @@ -537,7 +1065,22 @@ export function pageOf(persona, deliveries, msgpack, log = () => {}) { page.push(tuple); bytes += size; } - return [persona, page]; + if (!v3) return [persona, page]; + const lines = []; + const gapped = new Set(tail.gaps); + let full = false; + for (const line of tail.lines.slice().reverse()) { + let tuple, size; + try { tuple = docs.tailLineTuple(line); size = msgpack.encode(tuple).length; } catch (error) { + log(`tail line ${line?.eventId} skipped: ${error?.message ?? error}`); + gapped.add(line?.roomAlias); + continue; + } + if (full || bytes + size > docs.limits.replyBytes) { full = true; gapped.add(line.roomAlias); continue; } + lines.unshift(tuple); + bytes += size; + } + return [persona, page, lines, tail.rooms.filter(room => gapped.has(room))]; } // CultNet RUDP endpoint on loopback, one for every Persona in `mouths` (persona -> mouth). @@ -545,7 +1088,7 @@ export function pageOf(persona, deliveries, msgpack, log = () => {}) { // 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"); @@ -557,17 +1100,36 @@ 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, ...(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"); const message = cultnet.parseCultNetMessage(msgpack.decode(payload), "cultnet.schema.v0"); - const document = docs.checkEnvelope(message, [docs.kinds.pendingRequest, docs.kinds.ack, docs.kinds.read, docs.kinds.sendRequest, docs.kinds.sendRequestV2]); + const document = docs.checkEnvelope(message, null); + if (!served.includes(document.schemaId)) { // a kind this mouth does not serve is answered, loudly, and logged: never dropped + log(`chat endpoint refused a request of an unknown kind: ${String(document.schemaId).slice(0, 120)}`); + const who = typeof document.sourceAgentId === "string" && SLUG.test(document.sourceAgentId) ? document.sourceAgentId : "unknown"; + return respond(peer, who, docs.kinds.refusal, who, docs.refusalTuple({ persona: who, refusal: "UnknownKind" })); + } const tuple = msgpack.decode(document.payload); - if (document.schemaId === docs.kinds.pendingRequest) { + if (document.schemaId === docs.kinds.openDirectRequest) { + const request = docs.parseOpenDirectRequest(tuple); + 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.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); - respond(peer, request.persona, docs.kinds.pending, request.persona, pageOf(request.persona, mouth.pendingAfter(request.afterSeq), msgpack, log)); + 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, 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); @@ -619,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]; @@ -666,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, 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 }), @@ -677,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())); }); } diff --git a/tools/provider-advertisement.mjs b/tools/provider-advertisement.mjs index 32563af..6c6a4fb 100644 --- a/tools/provider-advertisement.mjs +++ b/tools/provider-advertisement.mjs @@ -179,9 +179,6 @@ const advertisementDefinition = defineDocumentType({ parse: (input) => parseAdvertisement(input, { requireCurrentContract: false }), }, name: "providerId", - indexes: { - serviceName: "serviceName", - }, members: [ { slot: 1, memberName: "providerId", typeName: "string", isName: true }, { slot: 2, memberName: "serviceName", typeName: "string", indexAlias: "serviceName" },