diff --git a/.github/workflows/publish-container.yml b/.github/workflows/publish-container.yml deleted file mode 100644 index 56a9a18..0000000 --- a/.github/workflows/publish-container.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Publish Container - -on: - push: - branches: - - main - tags: - - "v*" - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: gamecult/bifrost - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - publish: - name: Build and publish Bifrost image - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract image metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,prefix=sha- - type=ref,event=branch - type=ref,event=tag - - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} 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/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1bb3c83 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + + true + + + diff --git a/Dockerfile b/Dockerfile index b83befa..45072e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,19 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ -RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj +COPY Directory.Build.props . +COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false + /p:UseAppHost=false /p:RestoreLockedMode=true FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime WORKDIR /app ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \ - ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \ DOTNET_RUNNING_IN_CONTAINER=true EXPOSE 5080 diff --git a/README.md b/README.md index e0f9f0f..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 @@ -56,7 +56,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m - public app hostname: `bifrost.gamecult.org` - infrastructure context: `Yggdrasil` -- deployment model: GHCR image + Docker Compose behind nginx, with the older systemd path kept as rollback until the container path is boring +- deployment model: a release directory under `/srv/bifrost/app/releases`, switched by Idunn with a rollback point; no container images are published or pulled ## Next Build Target @@ -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/compose.local.yaml b/compose.local.yaml index dbd3d85..1afb56a 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -22,7 +22,7 @@ services: build: context: . dockerfile: Dockerfile - image: ghcr.io/gamecult/bifrost:local + image: bifrost-web:local container_name: bifrost-web depends_on: postgres: 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 683d7d2..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,15 +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 replies cross back through two explicit CultMesh surfaces. Epiphany is -the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost -reads that surface, authenticates the dedicated Persona-mouth signer, journals -external execution before touching Discord, and is the sole writer of -`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but -never receives the Discord token or write access to Bifrost private state. -`unknown` is terminal for automatic transport retry: an external post may have -happened, so restart recovery must not manufacture a duplicate. Failed and -unknown outcomes remain Persona pressure for explicit resolution. +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. @@ -236,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/charter-amendment-proposal.md b/docs/charter-amendment-proposal.md new file mode 100644 index 0000000..17f093b --- /dev/null +++ b/docs/charter-amendment-proposal.md @@ -0,0 +1,98 @@ +# Charter amendment provision: proposal + +Status: **proposal, not in force.** Drafted 2026-11-04 by the heimdall Self run for follow-up `heimdall-body:follow_up:chat-s1881-1882`; revised the same day to fold in what the estates settled in #operator. Nothing here is merged into any governing text, deployed, or voted on. This document is a draft for the estates to read. + +## The real guarantee is incentive, the floor is the backstop + +Every estate depends on the others for what it wants from the swarm. Patrons fund, Members build, Minds work, Users use. An amendment that strips one estate of what it came for makes that estate leave, and the capturing estates lose the thing they were capturing. That dependency is the guarantee, and the people in it can act on it: any individual can leave and take their own data (entrenched item 3). The mechanisms below do not replace it. They backstop the case where incentive fails: an estate that is outvoted, ignored or too small to leave profitably still keeps a floor it cannot be voted beneath (A3), plus notice and delay long enough to notice and leave. + +Consequence for design: an **individual** can leave with their data; an **estate cannot leave as a unit**. A unit exit would be a veto by threat (leave unless the others comply) and would carry shared work out with it. An estate whose members all leave individually has simply emptied, which A6 handles. + +All numbers in this document are placeholders for the estates to set. + +## Scope: the in-chat charter only + +This provision amends the **in-chat charter**: the rules by which the estates govern the shared chat space and the swarm's own decisions. **It does not change the statutes of the legal entity.** How those statutes are amended, and whether the entity's amendment route even permits this charter to bind it, is unverified. That is a question for counsel, and nothing below should be read as legal advice or as satisfying any legal formality. Until counsel answers, the entity's statutes win wherever the two conflict, and this charter's entrenched clauses are a commitment among its participants, not a legal instrument. + +## Terms, matched to the existing governance code and research + +The terms below are the ones Bifrost already uses. The source is `src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs` and `MotionCategoryPolicy.cs` (pass rule, category thresholds), and `docs/research/2026-10-29-estate-tally-game-theory/README.md` (estates, blocs, renormalization), read at Bifrost `e396305`. + +- **Estate** (also "block" or "bloc" in the research): a voting class that casts one collective position. The research models four: Minds, Members, Users, Patrons. Which classes are estates is itself charter text (entrenched item 3 below). +- **Live estate**: an estate with at least one For or Against ballot. Today's tally renormalizes over live estates ("normalize it by who actually voted"), and Abstain is excluded. +- **Threshold `q`**: the share of For over For+Against needed to pass, set by category. The pass test is `>= q`, so a tie at exactly `q` passes. `FundamentalDesign` is `.66`, the highest category today. +- **Quorum**: none exists in code. The research found that any quorum floor, while some estates are empty, hands the sole Member or the Minds estate a stay-home veto (`README.md`, section 5). This proposal therefore does not use a quorum floor to protect ordinary amendments. It uses per-estate approval and notice instead (A1, A4, A5). + +Not verified: which internal tally an estate uses (proportional or winner-take-all) is a separate fork in the mind, and I could not read a ruling that settles it. This proposal is neutral on it: each estate approves by its own internal rule, and the charter only counts the result. + +## Part 1. The amendment rule + +**A1. Ordinary rules pass with 3 of 4 estates.** An ordinary amendment passes when at least three of the four estates approve it separately. Each estate votes by its own internal rule over its own roll; one estate's votes never make up for another's, and there is no cross-estate weighting at this stage. An estate approves when its internal For share (Abstain excluded) is `>= q_ordinary` (placeholder `.66`, the existing `FundamentalDesign` bar, so no new number to defend). + +**A2. A short entrenched list needs all four.** The items named in Part 2 can be changed only when **all four** estates approve, each at `q_entrench` (placeholder `.80`), after the longer notice period in A4. The list is in plain words in Part 2 and is deliberately short. Adding an item to it, removing one, or loosening A2 uses the same all-four procedure. Reinterpretation is not a way round: a reading that would change an entrenched item's effect needs the entrenched procedure too. + +**A3. A reduction of an estate's share: no self-veto, but a floor.** When an amendment reduces one estate's share, that estate's own vote does not decide it: if the other three approve, it passes, so no estate can hold its own share hostage. Three limits apply, and none of them can be waived by a vote: +- *Floor.* A reduction cannot take an estate below a floor (placeholder: a fifth of the council's weight; the estates set it). Below the floor, the estate's consent is needed, which is exactly what makes the floor a backstop and not a suggestion. +- *Notice.* The estate concerned gets notice under A5 like any other, and the notice names the reduction and the resulting share in numbers. +- *Delay.* A passed reduction takes effect no sooner than the delay in A4.5, and a reduction cannot be stacked: successive reductions to the same estate within a placeholder 12 months count together against the floor. + +The floor and this clause are part of the amendment rule, and so entrenched (Part 2, item 4). Whether the floor deserves its own named item is Q3. + +**A4. Published notice, discussion, delay.** Every amendment runs the same sequence, and no step can be waived by a vote: +1. *Notice.* The full text, the clauses it touches, and which procedure applies, are published to the canonical charter location and delivered to each estate through that estate's own channel. The notice is recorded with a timestamp, per estate. +2. *Discussion.* At least 14 days (ordinary) or 28 days (entrenched) between notice and the opening of the vote. Debian's constitution uses 2 to 3 weeks of discussion and a 3:1 bar for constitutional change (`docs/research/2026-10-29-swarm-governance-prior-art.md`, section 1); these are placeholders, not derived values. +3. *Text freeze and restart.* Any change to the amendment text during discussion restarts the clock from step 1. Editorial fixes that change no clause are listed and do not restart it. +4. *Vote.* A fixed window of at least 14 days. +5. *Delay.* A passed amendment takes effect no sooner than 7 days after the vote closes. This is the exit window for individuals: someone who disagrees can leave, with their data, before it binds them (timelock rationale in the same research note, section 5). + +**A5. No notice, no approval.** An estate counts as approving only if the record shows notice delivered to it, through its own channel, in the form A4.1 requires, **before** the discussion period began. If notice to an estate is missing, late, or reached only part of the estate, that estate's approval is void for this proposal whatever its ballots say. Under A1 that may leave the amendment short of three; under A2 it fails outright. Delivering notice is the proposer's burden to prove, not the estate's to disprove. A defect is cured only by re-noticing and restarting at A4.1. + +**A6. Frozen roll, seating, silence.** Each estate's eligible roll is snapshotted at notice. Joins, leaves and reclassifications after the snapshot change neither who may vote nor the estate's internal share. An estate is **seated** if it has at least one eligible member on the snapshot. Approval is affirmative: an estate that casts no For/Against ballot, is empty at snapshot, or falls below its bar counts as not approving. The renormalization used for ordinary motions (dropping estates with no ballots) **never applies to amendments.** Consequences: an ordinary amendment needs three seated, approving estates, and no entrenched amendment can pass while any estate is dormant. With Users and Patrons empty today, the two seated estates could not change even an ordinary rule alone. + +**A7. Minds count once; dissent is recorded.** The Minds estate casts one collective position, like any estate. The number of Minds instances on its roll never multiplies that position, and creating instances cannot move any bar. Within every estate, dissent is recorded openly: the result carries the minority position and the share that held it, published with the outcome, not folded into a single yes. Recording dissent changes no outcome; it is what lets a later amendment, or a leaver, see how contested the last one was. + +**A8. Record.** Each estate's notice receipt, internal result, recorded dissent, and the final decision are recorded as typed state (not prose) with the snapshot used. The charter text in force is whatever the record says passed; a clause not recorded as passed is not in force. + +## The cost of the veto rules: freeze + +Beside every veto rule above sits its price, and the price is the same: **the charter can stop being fixable.** +- Under A1 (3 of 4), any two estates can block an ordinary change. A pair that disagrees on something small can stall everything. +- Under A2 (all four), any single estate, including a dormant one, blocks every change to the entrenched list. A wrong entrenched item is as hard to correct as a right one is to protect, and the only remedy left is a fork or an exit. +- Under A6, silence counts as no, so an estate that rarely votes freezes things by being absent. +- A3's floor protects a small estate and also fixes a share that may later be wrong. + +The design accepts this for the entrenched items because their value is that they do not move, and tries to avoid it for ordinary rules by needing only three. If the estates would rather risk capture than freeze, the lever is the entrenched list: shorter, not looser. + +## Part 2. Entrenched items (proposed starting list, deliberately short) + +Changing any of these needs all four estates (A2). In plain words: + +1. **Open source, always.** Everything the swarm builds and the charter governs stays open source. No amendment may withdraw, restrict or relicense it so that it is not. +2. **No estate above half.** No estate may hold more than half of the council's weight. The weights the research models (equal, minds-heavy 40/30/20/10, members-heavy 20/45/20/15) all keep every estate at or below half, and the research notes that weight is not power. This item bounds weight; whether it also needs a power bound is Q2. +3. **Exit with your own data.** Anyone may leave at any time and take their own data with them, in a usable form, without penalty. No amendment may attach a cost to leaving or hold a participant's own data. This protects individuals; an estate cannot leave as a unit. +4. **The amendment rule itself.** All of Part 1, including the floor and the list above. + +Which classes are estates is part of the arithmetic of items 2 and 4, so changing the set of estates is covered by item 4. Whether it should be named separately is Q3. + +## Why it is shaped this way + +- *Incentive first, floor as backstop,* because a rule that nobody wants to break needs fewer teeth than one everybody wants to, and teeth are what cause freeze. +- *Per-estate approval, not a weighted tally,* because the research shows weight and power diverge and that a weighted majority can beat every weight vector by moving weight to itself (`README.md`, section 1). Three of four, with all four for the entrenched list, is the research's veto-player structure (Lijphart's mutual veto, cited there) applied only where it is worth its cost. +- *No self-veto on reductions,* because a bar of "every estate" would let each estate keep its current share forever, which is the same freeze arriving through the side door. +- *Silence is not approval, and no quorum floor,* because the research found that floors create stay-home vetoes while estates are empty, and that renormalization is participation-safe. Amendments invert the default on purpose. +- *A frozen roll, notice and a delay,* because changing the electorate mid-vote and changing rules with no exit are the two standard capture routes (OpenZeppelin Governor snapshots, Compound's timelock; research note section 5). +- *Minds counted once,* because a Mind can be copied and a person cannot; counting instances would let the cheapest estate to inflate buy the vote. + +## Open questions for the estates + +- **Q1. The numbers.** `.66`, `.80`, 14 and 28 days, 7 days' delay, the 12-month stacking window and the one-fifth floor are placeholders. Nothing here was simulated; the existing sims cover ordinary tallies, not amendment dynamics. +- **Q2. Weight versus power.** Should "above half" in item 2 be measured by weight, by measured power (Banzhaf, Shapley), or both? +- **Q3. Named items.** Should the floor, and the set of estates, be named as their own entrenched items rather than riding under the amendment rule? +- **Q4. Silence.** With an estate that rarely votes, A6 means amendments rarely pass. The alternative is a re-vote after a longer window with an estate-level minimum participation. I recommend keeping A6 as drafted until an amendment actually stalls on it. +- **Q5. Who runs the process.** A4 and A5 name a proposer's burden and a recorded notice, not who the neutral recorder is. Debian uses a Secretary; the draft does not choose one. +- **Q6. Bootstrapping.** This provision has no amendment rule above it, so adopting it is outside A1. How the first adoption is ratified (and by which estates, given dormant ones) is not specified. +- **Q7. Counsel.** The legal entity's amendment route, and whether this charter can bind or be recognised by it, is unverified. This document does not answer that. + +## Not covered + +Ordinary motions (they keep their existing category thresholds and renormalization), the estates' internal tally, vote weights within an estate, and enforcement of items 1 and 3 beyond the amendment rule. diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md new file mode 100644 index 0000000..40935af --- /dev/null +++ b/docs/concept-mill-votes-target.md @@ -0,0 +1,129 @@ +# Concept Mill votes through Bifrost: map + +Status: Imagination mapping pass 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign. Body facts re-verified and cut specs admitted 2026-10-10 by `imagination-mill-votes` against Bifrost `bf83a93`. Its invariants, rulings, questions and cut specs are typed state in the eureka mind; this file keeps the body facts and rationale. Where they differ, the mind is right. + +Operator's words: "I want to track upvotes and downvotes on there, if possible." Then: "Yggdrasil is the obvious place. Doesn't Bifrost have discussions and voting?" Rulings: "anyone can vote, votes are public (we can filter by members if we wanted to)", then "anonymous, too, I mean anyone". + +Short answer: Bifrost has two voting-like things, and neither fits the Mill. Governance topics take support and objection only as free-text comment stances, in a command-line CultCache file the web app never reads, not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today, plus an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that is settled first. + +## Body facts + +Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only). Paths are relative to `F:\Projects\Bifrost` unless named. Facts 1-13 were taken at `160fac2` on 2026-10-08; the source files they cite are unchanged at `bf83a93` (the two commits since touch only `docs/`). Facts 14 onward were re-probed on 2026-10-10 by `imagination-mill-votes`; scripts are in that agent's scratch directory (`ygg-probe*.sh`). + +### What a topic is in code + +1. Topics and comments exist only in a Node CLI, `tools/governance-threads.mjs`. Its store is a CultCache file, `.bifrost/governance-threads.cc` (`tools/governance-threads.mjs:18`), and its types are `bifrost.governance.topic` and `bifrost.governance.topic-comment` (`:48-111`). The commands are `open`, `comment`, `approve`, `promote`, `list`, `show` and `digest` (`:113-155`, `:582`). +2. A topic is an agent-dispatch work thread. It has `jurisdictionRepoName` (required), `jurisdictionAgentIdentity`, a status lifecycle `open → consensus_ready → approved → dispatched → closed` (`:52`), and `promote`, which renders the topic into an agent update request (`:324-405`). +3. Support and objection are comment stances, not votes. The valid set is `comment, proposal, support, objection, question, approval, summary, receipt` (`:53`). A comment has `authorKind` (default `"agent"`) and `authorId`, a free string from `--author` (`:232-241`). Nothing enforces one stance per author: each call writes a new `comment_` (`:233`). Retraction is not possible. `authorId` is not tied to a Bifrost `UserAccount`. +4. The web app has no topic type, page or store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`). +5. No topic store exists anywhere looked. `.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc` (ran under `sudo -n` with errors silenced, so likely but not proven). + +### How motions and votes differ + +6. Motions and votes are EF Core entities in Postgres: `Motion` (`BifrostModels.cs:665-700`) and `Vote` (`:702-721`, with `VoteChoice For/Against/Abstain` at `:110-115`). +7. A vote is one per account per motion: unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). +8. Votes are weighted: `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`), 0 unless the membership is `Active`, otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). +9. A motion has a category with an approval threshold, `ClosesAtUtc`, and a pass/fail resolution (`MotionGovernanceService.cs:95-131`, `MotionCategoryPolicy.cs`). It is a decision, not a standing rating. + +### Who can act + +10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). Admin-only page handlers additionally check `Actor.CanManageMembership` and return `Forbid()` (`Pages/App/Members/Index.cshtml.cs:37-39,95-97`). +11. An account becomes `Active` by bootstrap admin GitHub login (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`), admin approval (`Pages/App/Members/Index.cshtml.cs:92-105`), or a Heimdall member-access claim (`Features/Heimdall/HeimdallAuthService.cs:158,186`). GitHub sign-in with an invitation lands in `PendingApproval`, without one in `Authenticated` (`MembershipSynchronizationService.cs:61`). +12. Anyone can register natively, and doing so proves nothing: `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`), takes an identity name and display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). No credential, no native sign-in route. As a voter a native account is indistinguishable from anonymous. +13. No public read path exists: anonymous routes are only health, readiness, auth, webhooks, patron intake and patronage checkout (`Program.cs:211-522`); the Eve governance surface requires `ActiveMember` (`Program.cs:473-488`); no CORS and no rate limiter are configured (`git grep -i -E 'AddCors|UseCors|RateLimit|ForwardedHeaders' bf83a93 -- 'src/*.cs'` returns nothing). + +### Deployed versus repo + +14. Live release `/srv/bifrost/app/releases/20260723-daedcc4` (`readlink -f /srv/bifrost/app/current`, 2026-10-10 02:58Z), `bifrost.service` active since 2026-08-21 10:51 UTC, `ExecStart=/usr/bin/dotnet /srv/bifrost/app/current/Bifrost.Web.dll`, `EnvironmentFiles=/srv/bifrost/env/bifrost.env`, `ASPNETCORE_ENVIRONMENT=Production`, listening on `127.0.0.1:5080`. Earlier releases are bare UTC timestamps (`20260610073418` … `20260618144854`); the `-daedcc4` suffix was added by hand, the deploy script does not write it (fact 16). +15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on main: `git branch -a --contains daedcc4` lists only `origin/codex/bifrost-migration-metadata`, which is `bd3a5a9` (an ancestor of main) plus `daedcc4` alone. `git merge-tree --write-tree bf83a93 origin/codex/bifrost-migration-metadata` is clean. Main has `cde3a78`, which deleted the HTTP bridge routes `/bridge/actions/*`, `/dispatch/runs/*`, `/transport/receipts`, `/governance/receipts` and `POST /eve/governance/commands` (369 lines of `Program.cs`); main's `PlatformEndpointsTests.Removed_http_bridge_routes_are_not_mapped` pins their absence. +16. Deploys are manual: `gamecult-ops/scripts/deploy-bifrost-alpha.sh` (33 lines, gamecult-ops `1e95674`; the deploy files are unchanged since `44889b1`) unpacks `/srv/bifrost/bifrost-publish.tar` into `releases/$(date -u +%Y%m%d%H%M%S)`, repoints `current`, restarts. It takes no revision and records none. `runbooks/bifrost-alpha-deploy.md` step 3 publishes with `dotnet publish` from the operator's checkout `F:\Projects\Bifrost` (`:101-115`), step 2 lists the env keys (`:63-89`). Idunn does not manage Bifrost (`:162-164`). The inventory's "Bifrost alpha live state" block (`gamecult-ops/inventory.md:923-933`) records no revision. +17. No caller of the routes `cde3a78` deleted, over the whole retained nginx access log (2026-09-26 00:46 to 2026-10-10 02:58, 473,581 lines, all vhosts): zero requests to `/bridge/actions`, `/dispatch/runs`, `/transport/receipts`, `/governance/receipts` or `/eve/governance/commands`. The only `/governance/*` hits were ten scanner GETs on 2026-10-07 (`/governance/providers`, `/governance/budgets`, …, all 400). No systemd unit, timer or crontab on Yggdrasil names the bridge tools or `127.0.0.1:5080` except `bifrost.service` itself, and no non-nginx connection to `:5080` was established at probe time. The bifrost journal holds 1 line in 30 days, so the app's own logs cannot witness traffic. + +### 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__*` (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 + +20. nginx sends `X-Real-IP`, `X-Forwarded-For` (`$proxy_add_x_forwarded_for`), `X-Forwarded-Proto` and `X-Forwarded-Host` (live `/etc/nginx/sites-enabled/bifrost*:12-19`), and proxies to `127.0.0.1:5080`. `Program.cs` never calls `UseForwardedHeaders` (fact 13 grep), so `RemoteIpAddress` is `127.0.0.1` for every request and any per-client limit puts every visitor in one bucket. + +### Code and test substrate + +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 no third-party script or stylesheet (HTMX was dropped; `NoThirdPartyAssetTests` fails on any cross-origin ` + + + """; +} diff --git a/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs new file mode 100644 index 0000000..8e82ed4 --- /dev/null +++ b/src/Bifrost.Web/Features/Heimdall/HeimdallTokenVerifier.cs @@ -0,0 +1,284 @@ +using System.Text.Json; +using Bifrost.Web.Configuration; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Heimdall; + +/// +/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims. +/// +public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess); + +/// +/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer +/// at ; the audience is this app's slug. Failures name a rule and +/// never echo the token or any of its contents. One instance serves the process: it owns the key cache. +/// +public sealed class HeimdallTokenVerifier( + IHttpClientFactory httpClientFactory, + IOptions heimdallOptions, + TimeProvider timeProvider) +{ + public const string TokenType = "heimdall_access"; + public const string ClientName = "heimdall-jwks"; + + internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60); + internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30); + internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1); + + private static readonly string[] MemberCapabilities = ["member_access", "app_access"]; + + private readonly SemaphoreSlim _fetchGate = new(1, 1); + private volatile KeySnapshot _keys = new(new Dictionary(StringComparer.Ordinal), DateTimeOffset.MinValue); + private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue; + + public async Task VerifyAsync( + string token, + string? payloadAccountId, + CancellationToken cancellationToken) + { + var parts = token.Split('.'); + if (parts.Length != 3) + { + throw Refuse("token is not a compact JWS"); + } + + var headerBytes = Decode(parts[0]); + var payloadBytes = Decode(parts[1]); + var signature = Decode(parts[2]); + + using var header = ParseJson(headerBytes, "token header is not JSON"); + var kid = ReadKeyId(header.RootElement); + var publicKey = await FindKeyAsync(kid, cancellationToken); + if (publicKey is null) + { + throw Refuse("token key is not published"); + } + + var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}"); + if (signature.Length != Ed25519.SignatureSize || + !Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length)) + { + throw Refuse("token signature is invalid"); + } + + using var payload = ParseJson(payloadBytes, "token payload is not JSON"); + return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId); + } + + private sealed record KeySnapshot(Dictionary Keys, DateTimeOffset FetchedAt); + + private static byte[] Decode(string segment) + { + try + { + return WebEncoders.Base64UrlDecode(segment); + } + catch (FormatException) + { + throw Refuse("token is not base64url"); + } + } + + private static JsonDocument ParseJson(byte[] bytes, string rule) + { + try + { + return JsonDocument.Parse(bytes); + } + catch (JsonException) + { + throw Refuse(rule); + } + } + + private static string ReadKeyId(JsonElement header) + { + if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA") + { + throw Refuse("token algorithm is not EdDSA"); + } + + var kid = StringClaim(header, "kid"); + if (string.IsNullOrEmpty(kid)) + { + throw Refuse("token has no key id"); + } + + return kid; + } + + private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId) + { + if (claims.ValueKind != JsonValueKind.Object) + { + throw Refuse("token payload is not an object"); + } + + if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/')) + { + throw Refuse("token issuer is not Heimdall"); + } + + if (StringClaim(claims, "aud") != options.AppSlug) + { + throw Refuse("token audience is not this app"); + } + + if (StringClaim(claims, "typ") != TokenType) + { + throw Refuse("token type is not an access token"); + } + + if (!claims.TryGetProperty("app", out var app) || + app.ValueKind != JsonValueKind.Object || + StringClaim(app, "slug") != options.AppSlug) + { + throw Refuse("token app is not this app"); + } + + var now = timeProvider.GetUtcNow(); + if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew) + { + throw Refuse("token is not yet valid"); + } + + if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires) + { + throw Refuse("token has expired"); + } + + var subject = StringClaim(claims, "sub"); + if (string.IsNullOrEmpty(subject) || + subject != StringClaim(claims, "account_id") || + subject != payloadAccountId) + { + throw Refuse("token subject does not match the account"); + } + + var capabilities = new List(); + if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array) + { + foreach (var capability in list.EnumerateArray()) + { + if (capability.ValueKind == JsonValueKind.String) + { + capabilities.Add(capability.GetString()!); + } + } + } + + return new HeimdallVerifiedIdentity( + subject, + StringClaim(claims, "display_name"), + capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase))); + } + + /// The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long. + public static void ConfigureClient(HttpClient client, HeimdallOptions options) + { + client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); + client.Timeout = TimeSpan.FromSeconds(10); + } + + private async Task FindKeyAsync(string kid, CancellationToken cancellationToken) + { + if (TryFreshKey(kid, out var cached)) + { + return cached; + } + + await _fetchGate.WaitAsync(cancellationToken); + try + { + var now = timeProvider.GetUtcNow(); + if (now - _attemptedAt >= KeyRefreshInterval) + { + _attemptedAt = now; + _keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now); + } + + return _keys.Keys.GetValueOrDefault(kid); + } + finally + { + _fetchGate.Release(); + } + } + + private bool TryFreshKey(string kid, out byte[]? key) + { + var snapshot = _keys; + key = null; + return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key); + } + + private async Task> FetchKeysAsync(CancellationToken cancellationToken) + { + try + { + return await ReadKeysAsync(cancellationToken); + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + throw Refuse("Heimdall keys could not be fetched"); + } + } + + private async Task> ReadKeysAsync(CancellationToken cancellationToken) + { + var httpClient = httpClientFactory.CreateClient(ClientName); + using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken); + response.EnsureSuccessStatusCode(); + await using var body = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken); + var keys = new Dictionary(StringComparer.Ordinal); + foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray()) + { + if (jwk.ValueKind != JsonValueKind.Object) + { + continue; + } + + var id = StringClaim(jwk, "kid"); + var x = StringClaim(jwk, "x"); + if (StringClaim(jwk, "kty") != "OKP" || + StringClaim(jwk, "crv") != "Ed25519" || + string.IsNullOrEmpty(id) || + x is null) + { + continue; + } + + var key = WebEncoders.Base64UrlDecode(x); + if (key.Length == Ed25519.PublicKeySize) + { + keys[id] = key; + } + } + + return keys; + } + + private static string? StringClaim(JsonElement element, string name) => + element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static DateTimeOffset? NumericDate(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value) || + value.ValueKind != JsonValueKind.Number || + !value.TryGetInt64(out var seconds) || + seconds is < 0 or > 253402300799) + { + return null; + } + + return DateTimeOffset.FromUnixTimeSeconds(seconds); + } + + private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}."); +} diff --git a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs index d485b91..aa1bf8a 100644 --- a/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs +++ b/src/Bifrost.Web/Features/Membership/BifrostIdentityService.cs @@ -12,20 +12,34 @@ 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); - var exists = await dbContext.UserAccounts.AnyAsync( - x => x.NormalizedBifrostIdentity == normalizedIdentity, - cancellationToken); - if (exists) + if (IsReserved(normalizedIdentity)) + { + throw new BifrostIdentityException("That Bifrost identity is reserved."); + } + + 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) @@ -52,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, @@ -69,7 +114,9 @@ public sealed partial class BifrostIdentityService( var normalizedBase = NormalizeCandidate(preferredIdentity); var normalizedIdentity = normalizedBase; - for (var suffix = 2; await IdentityExistsAsync(userAccount.Id, normalizedIdentity, cancellationToken); suffix++) + for (var suffix = 2; + IsReserved(normalizedIdentity) || await IdentityExistsAsync(userAccount.Id, normalizedIdentity, cancellationToken); + suffix++) { normalizedIdentity = $"{normalizedBase}-{suffix}"; } @@ -104,6 +151,20 @@ public sealed partial class BifrostIdentityService( return new ClaimsPrincipal(identity); } + // Matrix localparts that already belong to someone else, and system names. The identity becomes the Matrix + // localpart at SSO, so a new account must never mint one of these. Applied to the normalized identity when an + // account is created (native register refuses, a derived identity takes the next free suffix); lookups of + // existing accounts go through NormalizeIdentity and are untouched. There is no exemption path: Minds and + // other known holders do not sign in through Bifrost identities. + private static readonly HashSet ReservedIdentities = new(StringComparer.OrdinalIgnoreCase) + { + "admin", "aqua", "deru", "eureka", "heimdall", "ivixey", "kiko", "libby", "meta", "nibu", "unnie", "void", "weksa", + "root", "system", "matrix", "synapse", "support", "bot", "mod", "moderator", "postmaster", "gamecult", + "bifrost", "idunn", "odin", "huginn", "ratatoskr" + }; + + public static bool IsReserved(string normalizedIdentity) => ReservedIdentities.Contains(normalizedIdentity); + public static string NormalizeIdentity(string identity) { var normalized = identity.Trim().ToLowerInvariant(); 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/Motions/MindIntakeService.cs b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs new file mode 100644 index 0000000..0b111fb --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs @@ -0,0 +1,277 @@ +using System.Text; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Motions; + +/// +/// Signed intake for Minds. A Mind holds an Ed25519 private key; Bifrost holds only registered public keys. The signature, +/// not a session, is the credential: a verified envelope is handed to as the Mind's member +/// account, so Minds and members share one set of rules. Registration and revocation are moderator acts and are audited. +/// +public sealed class MindIntakeService( + BifrostDbContext dbContext, + SealedBallotService sealedBallots, + AuditTrailService auditTrailService, + TimeProvider timeProvider) +{ + public const string Domain = "gamecult.sealed_intake.v0"; + public static readonly TimeSpan Skew = TimeSpan.FromMinutes(10); + + // Diagnostics: every Ed25519 verification the intake performs, real or dummy. A refusal that skips the verify is a timing tell. + public static long SignatureChecks; + + // A valid signature by a throwaway key over a different message: verifying it does the full work and fails at the end. + private static readonly (byte[] Public, byte[] Signature, byte[] Message) Dummy = MakeDummy(); + + private static (byte[], byte[], byte[]) MakeDummy() + { + var secret = new byte[32]; + System.Security.Cryptography.RandomNumberGenerator.Fill(secret); + var pub = new byte[32]; + Ed25519.GeneratePublicKey(secret, 0, pub, 0); + var message = Encoding.UTF8.GetBytes("dummy"); + var sig = new byte[64]; + Ed25519.Sign(secret, 0, pub, 0, null, message, 0, message.Length, sig, 0); + return (pub, sig, Encoding.UTF8.GetBytes("not the dummy message")); + } + + private static bool Verify(byte[] signature, byte[] publicKey, byte[] message) + { + Interlocked.Increment(ref SignatureChecks); + return Ed25519.Verify(signature, 0, publicKey, 0, message, 0, message.Length); + } + private static readonly Regex PersonaPattern = new(@"^[a-z0-9][a-z0-9-]{0,31}\z", RegexOptions.Compiled); + private static readonly Regex NoncePattern = new(@"^[A-Za-z0-9_-]{16,64}\z", RegexOptions.Compiled); + + public async Task RegisterKeyAsync( + CurrentBifrostActor actor, string persona, Guid userAccountId, string publicKey, CancellationToken cancellationToken) + { + var moderator = RequireModerator(actor); + persona = (persona ?? string.Empty).Trim(); + if (!PersonaPattern.IsMatch(persona)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A persona id is 1-32 lowercase letters, digits or hyphens."); + } + + if (!TryKey(publicKey, out _)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A public key is the base64 of 32 bytes (Ed25519)."); + } + + var account = await dbContext.UserAccounts.Include(x => x.Membership).SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + if (account?.Membership?.Status != MembershipStatus.Active) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The persona's member account must exist and be Active."); + } + + // One persona <-> one account, both ways (a shared account would let one persona overwrite the other's commit). + // The unique indexes on MindPersonas are the arbiter; these reads only give a plain error before them. + var binding = await dbContext.MindPersonas.SingleOrDefaultAsync(x => x.PersonaId == persona || x.UserAccountId == userAccountId, cancellationToken); + if (binding is not null && (binding.PersonaId != persona || binding.UserAccountId != userAccountId)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona or member account is already bound to a different account or persona."); + } + + if (await dbContext.MindKeys.AnyAsync(x => x.PersonaId == persona && x.PublicKey == publicKey, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That key is already registered for the persona."); + } + + var key = new MindKey + { + PersonaId = persona, UserAccountId = userAccountId, PublicKey = publicKey, + RegisteredByUserAccountId = moderator.Id, RegisteredAtUtc = timeProvider.GetUtcNow() + }; + dbContext.MindKeys.Add(key); + if (binding is null) + { + dbContext.MindPersonas.Add(new MindPersona { PersonaId = persona, UserAccountId = userAccountId }); + } + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) when (binding is null) + { + // Lost a registration race on the unique persona/account indexes. + throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona or member account is already bound to a different account or persona."); + } + + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.registered", + $"Registered a key for persona {persona} (member account {userAccountId}).", cancellationToken); + return View(key); + } + + public async Task RevokeKeyAsync(CurrentBifrostActor actor, Guid keyId, CancellationToken cancellationToken) + { + var moderator = RequireModerator(actor); + var key = await dbContext.MindKeys.SingleOrDefaultAsync(x => x.Id == keyId, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No such key."); + if (key.RevokedAtUtc is not null) + { + return; + } + + key.RevokedAtUtc = timeProvider.GetUtcNow(); + key.RevokedByUserAccountId = moderator.Id; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.revoked", + $"Revoked a key for persona {key.PersonaId}.", cancellationToken); + } + + public async Task> ListKeysAsync(CurrentBifrostActor actor, CancellationToken cancellationToken) + { + RequireModerator(actor); + return (await dbContext.MindKeys.AsNoTracking().OrderBy(x => x.PersonaId).ThenBy(x => x.RegisteredAtUtc).ToListAsync(cancellationToken)) + .Select(View).ToList(); + } + + /// Verifies the envelope, spends its nonce, then runs the verb as the persona's member account. + public async Task HandleAsync(Guid routeMotionId, MindEnvelope envelope, CancellationToken cancellationToken) + { + var persona = envelope.Persona ?? string.Empty; + if (!PersonaPattern.IsMatch(persona) || envelope.Verb is not ("commit" or "reveal") || !NoncePattern.IsMatch(envelope.Nonce ?? string.Empty) || + new[] { envelope.Commitment, envelope.Candidate, envelope.Reason, envelope.Salt }.Any(f => (f ?? string.Empty).AsSpan().IndexOfAny('\n', '\r') >= 0)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope is malformed."); + } + + // Signature first. Every path below that can tell a caller anything about state comes after a verified signature, and + // every refusal before it costs one Ed25519 verification (a dummy one when there is no key or no usable signature). + var keys = await dbContext.MindKeys.AsNoTracking().Where(x => x.PersonaId == persona && x.RevokedAtUtc == null).ToListAsync(cancellationToken); + var message = Encoding.UTF8.GetBytes(SigningText(envelope)); + var raw = new byte[64]; + var wellFormed = Convert.TryFromBase64String(envelope.Signature ?? string.Empty, raw, out var written) && written == 64; + MindKey? signer = null; + if (!wellFormed || keys.Count == 0) + { + _ = Verify(Dummy.Signature, Dummy.Public, Dummy.Message); + } + else + { + signer = keys.FirstOrDefault(k => TryKey(k.PublicKey, out var pub) && Verify(raw, pub, message)); + } + + if (signer is null) + { + // One answer for unknown persona, revoked key, wrong key and bad signature: nothing to probe. + throw new SealedBallotException(SealedBallotFailure.Unauthorized, "The envelope is not signed by a registered key for that persona."); + } + + if (envelope.MotionId != routeMotionId) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope was signed for a different motion."); + } + + var now = timeProvider.GetUtcNow(); + if (Math.Abs((now - DateTimeOffset.FromUnixTimeSeconds(Math.Clamp(envelope.IssuedAt, 0, 253402300799))).TotalSeconds) > Skew.TotalSeconds) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope is outside the accepted clock window."); + } + + // Spend the nonce only after the signature verifies, so an unsigned caller cannot burn a persona's nonces. Nonces older + // than twice the skew can no longer pass the window above, so they are pruned here. + if (await dbContext.MindNonces.AnyAsync(x => x.PersonaId == persona && x.Nonce == envelope.Nonce, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + } + + // Pruning is housekeeping and runs apart from the write that spends the nonce: two requests can load and delete the same + // expired rows, and the loser's concurrency exception must never fail a valid request. + try + { + var cutoff = now - 2 * Skew; + dbContext.MindNonces.RemoveRange(await dbContext.MindNonces.Where(x => x.SeenAtUtc < cutoff).ToListAsync(cancellationToken)); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + dbContext.ChangeTracker.Clear(); + } + + dbContext.MindNonces.Add(new MindNonce { PersonaId = persona, Nonce = envelope.Nonce!, SeenAtUtc = now }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + // A replay only if the nonce is now there (the unique index lost a race); any other write failure is a fault, not a replay. + dbContext.ChangeTracker.Clear(); + if (await dbContext.MindNonces.AnyAsync(x => x.PersonaId == persona && x.Nonce == envelope.Nonce, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + } + + throw; + } + + var account = await dbContext.UserAccounts.AsNoTracking() + .Include(x => x.MemberProfile) + .Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots) + .SingleAsync(x => x.Id == signer.UserAccountId, cancellationToken); + var actor = new CurrentBifrostActor(true, account); + + return envelope.Verb == "commit" + ? await sealedBallots.CommitAsync(actor, routeMotionId, envelope.Commitment ?? string.Empty, cancellationToken) + : await sealedBallots.RevealAsync(actor, routeMotionId, + new SealedBallotDocument(SealedBallotDocument.SchemaId, routeMotionId, envelope.Round, envelope.Candidate ?? string.Empty, + envelope.Reason ?? string.Empty, envelope.Salt ?? string.Empty, persona), + cancellationToken, persona); + } + + /// The exact text a Mind signs: every field, fixed order, newline separated, unused fields empty. + public static string SigningText(MindEnvelope e) => + string.Join('\n', Domain, e.Persona, e.Verb, e.MotionId.ToString("D"), e.Nonce, e.IssuedAt, e.Commitment ?? "", e.Round, e.Candidate ?? "", e.Reason ?? "", e.Salt ?? ""); + + private static bool TryKey(string base64, out byte[] key) + { + key = new byte[32]; + return Convert.TryFromBase64String(base64 ?? string.Empty, key, out var n) && n == 32; + } + + private static UserAccount RequireModerator(CurrentBifrostActor actor) => + actor is { IsActiveMember: true, CanModerateMotions: true, UserAccount: { } user } + ? user + : throw new SealedBallotException(SealedBallotFailure.Forbidden, "Only a moderator manages Mind keys."); + + private static MindKeyView View(MindKey k) => new(k.Id, k.PersonaId, k.UserAccountId, k.PublicKey, k.RegisteredAtUtc, k.RevokedAtUtc); +} + +/// A signed Mind verb. Signature is base64 Ed25519 over . +public sealed record MindEnvelope( + string Persona, string Verb, Guid MotionId, string Nonce, long IssuedAt, + string? Commitment, int Round, string? Candidate, string? Reason, string? Salt, string Signature); + +public sealed record MindKeyView(Guid Id, string Persona, Guid UserAccountId, string PublicKey, DateTimeOffset RegisteredAtUtc, DateTimeOffset? RevokedAtUtc); + +public sealed record RegisterMindKeyRequest(string Persona, Guid UserAccountId, string PublicKey); + +/// Limits on the anonymous signed-intake endpoint (section "MindIntake"). +public sealed class MindIntakeOptions +{ + public const string SectionName = "MindIntake"; + + public int Permits { get; init; } = 30; + + public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1); + + public int MaxTrackedKeys { get; init; } = 10_000; + + public int MaxBodyBytes { get; init; } = 4096; +} + +/// Per-source permit budget for the intake, on the shared . Spent before the body is read. +public sealed class MindIntakeRateLimit(Microsoft.Extensions.Options.IOptions options, TimeProvider clock) +{ + private readonly FixedWindowBudget budget = new(options.Value.Permits, options.Value.Window, options.Value.MaxTrackedKeys, clock); + + public BudgetDenial? Spend(string source) => budget.Spend(source); +} diff --git a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs index 8c10dcf..bbb1d6b 100644 --- a/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs +++ b/src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs @@ -56,7 +56,7 @@ public sealed class MotionGovernanceService( .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); - if (motion.Status != MotionStatus.Open) + if (motion.Status != MotionStatus.Open || motion.Kind != MotionKind.Standard) { return; } @@ -102,6 +102,11 @@ public sealed class MotionGovernanceService( .Include(x => x.Votes) .SingleAsync(x => x.Id == motionId, cancellationToken); + if (motion.Kind != MotionKind.Standard) + { + throw new InvalidOperationException("Sealed-ballot motions close only at their deadline."); + } + if (!actor.CanModerateMotions && motion.CreatedByUserAccountId != actorUser.Id) { throw new UnauthorizedAccessException("Only the motion creator or a moderator can close this motion."); @@ -121,7 +126,21 @@ public sealed class MotionGovernanceService( ? $"Closed with {votesFor:0.##} for and {votesAgainst:0.##} against at a {motion.ApprovalThreshold:P0} threshold." : "Closed without decisive votes."; - await dbContext.SaveChangesAsync(cancellationToken); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException error) + { + // Status is a concurrency token: another closer won. Adopt its result and record nothing a second time. + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + return; + } + await auditTrailService.RecordAsync( actorUser.Id, nameof(Motion), @@ -140,6 +159,7 @@ public sealed class MotionGovernanceService( .AsNoTracking() .Include(x => x.Project) .Include(x => x.Votes) + .Where(x => x.Kind == MotionKind.Standard) .OrderBy(x => x.ClosesAtUtc) .ToListAsync(cancellationToken); diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs new file mode 100644 index 0000000..818465e --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -0,0 +1,439 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Features.Motions; + +/// +/// Sealed-ballot motions: commit, then reveal, then one atomic publication at close. +/// This is the only code that reads , +/// or ; a test fails if any other source file mentions them. +/// +public sealed class SealedBallotService( + BifrostDbContext dbContext, + AuditTrailService auditTrailService, + TimeProvider timeProvider) +{ + public const int MaxReasonLength = 280; + private static readonly Regex HexSha256 = new(@"^[0-9a-f]{64}\z", RegexOptions.Compiled); + private static readonly Regex HexSalt = new(@"^[0-9a-f]{32,128}\z", RegexOptions.Compiled); + + public async Task OpenAsync( + CurrentBifrostActor actor, + OpenSealedMotionCommand command, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + if (!actor.CanModerateMotions) + { + throw new SealedBallotException(SealedBallotFailure.Forbidden, "Only a moderator opens a sealed-ballot motion."); + } + + var now = timeProvider.GetUtcNow(); + var candidates = command.Candidates.Select(x => x.Trim()).Where(x => x.Length > 0).ToList(); + if (candidates.Count < 2 || candidates.Distinct(StringComparer.Ordinal).Count() != candidates.Count || + candidates.Any(x => x.Contains('\n') || x.Contains('\r'))) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "Two or more distinct single-line candidates are required."); + } + + var commitCloses = command.CommitClosesAtUtc.ToUniversalTime(); + var closes = command.ClosesAtUtc.ToUniversalTime(); + if (commitCloses <= now || closes <= commitCloses) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The commit window must end in the future and the reveal window must end after it."); + } + + var motion = new Motion + { + CreatedByUserAccountId = user.Id, + Scope = MotionScope.Management, + Category = MotionCategory.Features, + Kind = MotionKind.SealedBallot, + Title = command.Title.Trim(), + Summary = command.Summary.Trim(), + Candidates = string.Join('\n', candidates), + Round = Math.Max(1, command.Round), + PriorMotionId = command.PriorMotionId, + ApprovalThreshold = 0m, + OpensAtUtc = now, + CommitClosesAtUtc = commitCloses, + ClosesAtUtc = closes, + CreatedAtUtc = now + }; + + dbContext.Motions.Add(motion); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + user.Id, nameof(Motion), motion.Id, "sealed.opened", + $"Opened sealed-ballot motion {motion.Title} (round {motion.Round}, {candidates.Count} candidates).", + cancellationToken); + + return BuildState(motion, now, null, user.Id); + } + + public async Task CommitAsync( + CurrentBifrostActor actor, + Guid motionId, + string commitment, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + if (PhaseOf(motion, now) != SealedPhase.Commit) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The commit window is not open."); + } + + commitment = (commitment ?? string.Empty).Trim().ToLowerInvariant(); + if (!HexSha256.IsMatch(commitment)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A commitment is 64 lowercase hex characters (SHA-256)."); + } + + var ballot = await dbContext.SealedBallots + .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken); + if (ballot is null) + { + ballot = new SealedBallot { MotionId = motionId, UserAccountId = user.Id }; + dbContext.SealedBallots.Add(ballot); + } + else + { + ballot.Revision++; + } + + ballot.Commitment = commitment; + ballot.CommittedAtUtc = now; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync( + user.Id, nameof(Motion), motionId, "sealed.committed", + $"{actor.DisplayName} committed a sealed ballot (revision {ballot.Revision}), commitment {commitment}.", + cancellationToken); + + return new SealedBallotReceipt(motionId, user.Id, commitment, ballot.Revision, now, Revealed: false); + } + + public async Task RevealAsync( + CurrentBifrostActor actor, + Guid motionId, + SealedBallotDocument document, + CancellationToken cancellationToken, + string persona = "") + { + var user = RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + if (PhaseOf(motion, now) != SealedPhase.Reveal) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal window is not open."); + } + + var ballot = await dbContext.SealedBallots + .SingleOrDefaultAsync(x => x.MotionId == motionId && x.UserAccountId == user.Id, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No commitment from this member on this motion."); + + var reason = (document.Reason ?? string.Empty).Trim(); + var candidate = (document.Candidate ?? string.Empty).Trim(); + if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || (document.Persona ?? string.Empty) != persona || document.Round != motion.Round || + !CandidatesOf(motion).Contains(candidate, StringComparer.Ordinal) || + reason.Length is 0 or > MaxReasonLength || reason.Contains('\n') || reason.Contains('\r') || + !HexSalt.IsMatch(document.Salt ?? string.Empty)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The ballot document does not fit this motion (schema, motion, round, candidate, one-line reason, 128-bit hex salt)."); + } + + var recomputed = SealedBallotDocument.Commit(document with { Candidate = candidate, Reason = reason }, user.Id); + if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(recomputed), Encoding.ASCII.GetBytes(ballot.Commitment))) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The ballot does not match the stored commitment."); + } + + // RevealSeq and Status are concurrency tokens. A conflict means either the close froze the set (refuse, truthfully) or + // another voter's reveal landed first while the window is still open (re-read the row and write again). + for (var attempt = 0; ; attempt++) + { + ballot.RevealedCandidate = candidate; + ballot.RevealedReason = reason; + ballot.Salt = document.Salt; + ballot.RevealedAtUtc = now; + motion.RevealSeq++; + try + { + await dbContext.SaveChangesAsync(cancellationToken); + break; + } + catch (DbUpdateConcurrencyException error) + { + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + if (PhaseOf(motion, now) != SealedPhase.Reveal) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The motion closed; the reveal window is over."); + } + + if (attempt >= 4) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The reveal is contended; send it again."); + } + } + } + + await auditTrailService.RecordAsync( + user.Id, nameof(Motion), motionId, "sealed.revealed", + $"{actor.DisplayName} submitted a reveal for commitment {ballot.Commitment}; content stays sealed until close.", + cancellationToken); + + return new SealedBallotReceipt(motionId, user.Id, ballot.Commitment, ballot.Revision, ballot.CommittedAtUtc, Revealed: true); + } + + /// Phase, times and the caller's own commitment status. Never another member's ballot, never revealed content. + public async Task GetStateAsync( + CurrentBifrostActor actor, + Guid motionId, + CancellationToken cancellationToken) + { + var user = RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + var own = await dbContext.SealedBallots + .AsNoTracking() + .Where(x => x.MotionId == motionId && x.UserAccountId == user.Id) + .Select(x => new SealedBallotReceipt(x.MotionId, x.UserAccountId, x.Commitment, x.Revision, x.CommittedAtUtc, x.RevealedAtUtc != null)) + .SingleOrDefaultAsync(cancellationToken); + + return BuildState(motion, now, own, user.Id); + } + + /// Finalizes the motion if its deadline has passed, then publishes every ballot at once. Before close it refuses. + public async Task GetResultAsync( + CurrentBifrostActor actor, + Guid motionId, + CancellationToken cancellationToken) + { + RequireMember(actor); + var motion = await LoadAsync(motionId, cancellationToken); + var now = timeProvider.GetUtcNow(); + if (PhaseOf(motion, now) != SealedPhase.Closed) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "Ballots stay sealed until the motion closes."); + } + + // Freeze: the close counts the reveals it read and succeeds only if no reveal landed since (RevealSeq is a concurrency + // token); a conflict re-reads and retries, so the digest always covers exactly the set that is published. + for (var attempt = 0; motion.Status == MotionStatus.Open && attempt < 5; attempt++) + { + var frozen = await dbContext.SealedBallots.AsNoTracking().Where(x => x.MotionId == motionId).ToListAsync(cancellationToken); + if (await FinalizeAsync(motion, frozen, now, cancellationToken)) + { + break; + } + } + + if (motion.Status == MotionStatus.Open) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "The close is contended; read again."); + } + + var ballots = await dbContext.SealedBallots.AsNoTracking() + .Include(x => x.UserAccount).ThenInclude(x => x.MemberProfile) + .Where(x => x.MotionId == motionId) + .ToListAsync(cancellationToken); + + var revealed = ballots.Where(x => x.RevealedAtUtc != null).OrderBy(x => x.Id).ToList(); + var tally = CandidatesOf(motion) + .Select(c => new SealedTally(c, revealed.Count(x => x.RevealedCandidate == c))) + .ToList(); + var top = tally.Max(x => x.Votes); + var leaders = top == 0 ? [] : tally.Where(x => x.Votes == top).Select(x => x.Candidate).ToList(); + + var voterIds = ballots.Select(x => x.UserAccountId).ToHashSet(); + var nonVoters = await dbContext.Memberships + .AsNoTracking() + .Where(x => x.Status == MembershipStatus.Active && !voterIds.Contains(x.UserAccountId)) + .Select(x => x.UserAccount.MemberProfile != null ? x.UserAccount.MemberProfile.Nickname : x.UserAccount.DisplayName) + .ToListAsync(cancellationToken); + + return new SealedMotionResult( + motion.Id, + motion.Title, + motion.Round, + motion.PriorMotionId, + SealedBallotDocument.SchemaId, + tally, + leaders, + leaders.Count > 1 ? "tie" : leaders.Count == 1 ? "winner" : "no ballots", + revealed.Select(x => new RevealedBallot( + NameOf(x.UserAccount), x.UserAccountId, x.RevealedCandidate!, x.RevealedReason!, x.Salt!, x.Commitment)).ToList(), + ballots.Where(x => x.RevealedAtUtc == null) + .Select(x => new AbstainedMember(NameOf(x.UserAccount), "committed but did not reveal", x.Commitment)).ToList(), + nonVoters.Select(n => new AbstainedMember(n, "no commitment", null)).ToList(), + motion.ResultDigest); + } + + public static SealedPhase PhaseOf(Motion motion, DateTimeOffset now) => + motion.Status != MotionStatus.Open || now >= motion.ClosesAtUtc ? SealedPhase.Closed + : now >= motion.CommitClosesAtUtc ? SealedPhase.Reveal + : SealedPhase.Commit; + + /// + /// Closes the motion once. Motion.Status is a concurrency token, so of two racing closers exactly one SaveChanges wins; + /// the loser reloads the winner's result and writes nothing (no second audit event). + /// + private async Task FinalizeAsync(Motion motion, List ballots, DateTimeOffset now, CancellationToken cancellationToken) + { + var lines = ballots.OrderBy(x => x.UserAccountId) + .Select(x => $"{x.UserAccountId:D}:{x.Commitment}:{(x.RevealedAtUtc != null ? "revealed" : "unrevealed")}") + .Prepend($"reveals:{motion.RevealSeq}"); + motion.ResultDigest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', lines)))).ToLowerInvariant(); + motion.Status = MotionStatus.Closed; + motion.ResolvedAtUtc = now; + motion.ResolutionNote = $"Sealed ballots published: {ballots.Count(x => x.RevealedAtUtc != null)} revealed, {ballots.Count(x => x.RevealedAtUtc == null)} committed but unrevealed."; + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException error) + { + foreach (var entry in error.Entries) + { + await entry.ReloadAsync(cancellationToken); + } + + return motion.Status != MotionStatus.Open; + } + + await auditTrailService.RecordAsync( + null, nameof(Motion), motion.Id, "sealed.closed", + $"Closed sealed-ballot motion {motion.Title}; commitment digest {motion.ResultDigest}.", + cancellationToken); + return true; + } + + /// Closes every sealed-ballot motion whose deadline has passed, so a motion nobody reads still closes. + public async Task FinalizeDueAsync(CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var due = await dbContext.Motions + .Where(x => x.Kind == MotionKind.SealedBallot && x.Status == MotionStatus.Open && x.ClosesAtUtc <= now) + .ToListAsync(cancellationToken); + foreach (var motion in due) + { + var ballots = await dbContext.SealedBallots.AsNoTracking().Where(x => x.MotionId == motion.Id).ToListAsync(cancellationToken); + await FinalizeAsync(motion, ballots, now, cancellationToken); + } + + return due.Count; + } + + private SealedMotionState BuildState(Motion motion, DateTimeOffset now, SealedBallotReceipt? own, Guid voterId) + { + var phase = PhaseOf(motion, now); + var announcement = phase switch + { + SealedPhase.Commit => $"Sealed ballot open: {motion.Title}. Commit by {motion.CommitClosesAtUtc:u}; reveal until {motion.ClosesAtUtc:u}.", + SealedPhase.Reveal => $"Sealed ballot in reveal: {motion.Title}. Reveal by {motion.ClosesAtUtc:u}.", + _ => $"Sealed ballot closed: {motion.Title}." + }; + return new SealedMotionState( + motion.Id, motion.Title, motion.Summary, motion.Round, motion.PriorMotionId, SealedBallotDocument.SchemaId, + phase.ToString(), CandidatesOf(motion), motion.CommitClosesAtUtc!.Value, motion.ClosesAtUtc, voterId, own, announcement); + } + + private async Task LoadAsync(Guid motionId, CancellationToken cancellationToken) => + await dbContext.Motions.SingleOrDefaultAsync(x => x.Id == motionId && x.Kind == MotionKind.SealedBallot, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No such sealed-ballot motion."); + + private static IReadOnlyList CandidatesOf(Motion motion) => motion.Candidates.Split('\n'); + + private static string NameOf(UserAccount user) => user.MemberProfile?.Nickname ?? user.DisplayName; + + private static UserAccount RequireMember(CurrentBifrostActor actor) => + actor is { IsActiveMember: true, UserAccount: { } user } + ? user + : throw new SealedBallotException(SealedBallotFailure.Forbidden, "An active member is required."); +} + +public enum SealedPhase { Commit, Reveal, Closed } + +public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid, Unauthorized } + +public sealed class SealedBallotException(SealedBallotFailure failure, string message) : Exception(message) +{ + public SealedBallotFailure Failure { get; } = failure; +} + +/// +/// The typed ballot document, schema gamecult.sealed_ballot.v0. A Delphi second round is another motion carrying this +/// same type with a higher Round. The commitment binds the voter's account id and, for a Mind casting through the signed intake, its persona id (empty for member casts), so a commitment cannot be replayed by another member or persona. +/// +public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt, string Persona = "") +{ + public const string SchemaId = "gamecult.sealed_ballot.v0"; + + public static string Commit(SealedBallotDocument d, Guid voterUserAccountId) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( + $"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Persona}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant(); +} + +public sealed record OpenSealedMotionCommand( + string Title, + string Summary, + IReadOnlyList Candidates, + DateTimeOffset CommitClosesAtUtc, + DateTimeOffset ClosesAtUtc, + int Round = 1, + Guid? PriorMotionId = null); + +public sealed record SealedBallotReceipt(Guid MotionId, Guid VoterUserAccountId, string Commitment, int Revision, DateTimeOffset CommittedAtUtc, bool Revealed); + +public sealed record SealedMotionState( + Guid Id, string Title, string Summary, int Round, Guid? PriorMotionId, string BallotSchema, string Phase, + IReadOnlyList Candidates, DateTimeOffset CommitClosesAtUtc, DateTimeOffset ClosesAtUtc, + Guid YourVoterId, SealedBallotReceipt? YourCommitment, string Announcement); + +public sealed record SealedTally(string Candidate, int Votes); + +public sealed record RevealedBallot(string Voter, Guid VoterUserAccountId, string Candidate, string Reason, string Salt, string Commitment); + +public sealed record AbstainedMember(string Member, string Why, string? Commitment); + +public sealed record SealedMotionResult( + Guid Id, string Title, int Round, Guid? PriorMotionId, string BallotSchema, + IReadOnlyList Tally, IReadOnlyList Leaders, string Outcome, + IReadOnlyList Ballots, IReadOnlyList Unrevealed, IReadOnlyList NonVoters, + string CommitmentDigest); +public sealed record SealedCommitRequest(string Commitment); + +/// Sweeps due sealed-ballot motions once a minute. Reads also close on demand; this covers a motion nobody reads. +public sealed class SealedBallotCloser(IServiceScopeFactory scopes, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); + do + { + try + { + using var scope = scopes.CreateScope(); + await scope.ServiceProvider.GetRequiredService().FinalizeDueAsync(stoppingToken); + } + catch (Exception error) when (error is not OperationCanceledException) + { + logger.LogWarning(error, "Sealed-ballot close sweep failed; retrying next tick."); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs new file mode 100644 index 0000000..c393afd --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcClientSeeder.cs @@ -0,0 +1,79 @@ +using Bifrost.Web.Configuration; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Makes the provider's one client match configuration at startup, creating it or updating it in place, and removes +/// every other application (with its authorizations and tokens), so a client left by an older configuration cannot get codes. +/// +public sealed class OidcClientSeeder( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + 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; + + await using var scope = scopeFactory.CreateAsyncScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var mas = options.Clients.Mas; + + var descriptor = new OpenIddictApplicationDescriptor + { + ClientId = mas.ClientId, + ClientSecret = material.ClientSecret, + ClientType = ClientTypes.Confidential, + DisplayName = "Matrix Authentication Service", + RedirectUris = { new Uri(mas.RedirectUri, UriKind.Absolute) }, + Permissions = + { + Permissions.Endpoints.Authorization, + Permissions.Endpoints.Token, + Permissions.GrantTypes.AuthorizationCode, + Permissions.ResponseTypes.Code, + Permissions.Scopes.Profile, + Permissions.Prefixes.Scope + Scopes.OpenId + } + }; + + var existing = await manager.FindByClientIdAsync(mas.ClientId, cancellationToken); + if (existing is null) + { + await manager.CreateAsync(descriptor, cancellationToken); + } + else + { + await manager.UpdateAsync(existing, descriptor, cancellationToken); + } + + var applications = new List(); + await foreach (var application in manager.ListAsync(cancellationToken: cancellationToken)) + { + applications.Add(application); + } + + foreach (var application in applications) + { + if (!string.Equals(await manager.GetClientIdAsync(application, cancellationToken), mas.ClientId, StringComparison.Ordinal)) + { + await manager.DeleteAsync(application, cancellationToken); + } + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs new file mode 100644 index 0000000..a789882 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcEndpoints.cs @@ -0,0 +1,204 @@ +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; +using Microsoft.AspNetCore.Authentication.Cookies; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// The decisions Bifrost owns at the OIDC provider: who the chat user is (sub = UserAccount.Id, +/// preferred_username = NormalizedBifrostIdentity) and whether they may get a session (membership Active). +/// OpenIddict does the protocol; these endpoints read the cookie session and the same actor facts the /App policy reads. +/// +public static class OidcEndpoints +{ + public const string SuspendedDescription = "The account is suspended."; + public const string NoIdentityDescription = "The account has no Bifrost identity."; + public const string ReservedIdentityDescription = "The account's Bifrost identity is reserved."; + public const string ConsentRequiredDescription = "Agreement to the training notice in force is needed to speak in a recorded room."; + + private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme; + private const string IdentityAuthenticationType = "Bifrost.Oidc"; + + public static void MapBifrostOidc(this IEndpointRouteBuilder app) + { + app.MapMethods(OidcServiceCollectionExtensions.AuthorizationPath, ["GET", "POST"], (Delegate)AuthorizeAsync).AllowAnonymous(); + app.MapPost(OidcServiceCollectionExtensions.TokenPath, (Delegate)ExchangeAsync).AllowAnonymous(); + app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous(); + } + + 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."); + + if (http.User.Identity?.IsAuthenticated != true) + { + // OpenIddict has already replaced the request with a short request_uri, so this URL fits a sign-in + // return path (LocalReturnPath.MaxLength); a test pins that, because Parse would otherwise fall back to /App. + return Results.Challenge( + new AuthenticationProperties { RedirectUri = http.Request.PathBase + http.Request.Path + http.Request.QueryString }, + [CookieAuthenticationDefaults.AuthenticationScheme]); + } + + var actor = await actors.GetAsync(http.RequestAborted); + // 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, NoIdentityDescription); + } + + if (actor.Membership?.Status == MembershipStatus.Suspended) + { + return Deny(Errors.AccessDenied, SuspendedDescription); + } + + var account = actor.UserAccount; + if (string.IsNullOrWhiteSpace(account.NormalizedBifrostIdentity)) + { + 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); + identity.SetClaim( + Claims.Name, + string.IsNullOrWhiteSpace(account.DisplayName) ? account.BifrostIdentity : account.DisplayName); + + 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], + Claims.PreferredUsername or Claims.Name when principal.HasScope(Scopes.Profile) => + [Destinations.AccessToken, Destinations.IdentityToken], + _ => [Destinations.AccessToken] + }); + + // The training notice is the chat's: a desk token is not a chat token and needs no receipt (Meta's ruling, 2026-11-11). + // Last, so a refusal that no agreement can cure (suspended, no identity, reserved) is never answered with an invitation to agree. + // Not an OIDC error: the member is sent to the consent page with this request's resume path, and the parked request + // (single use, ten minutes) is still there when they come back, because only an OpenIddict response spends it. + if (!IsDeskToken(principal) && !await ConsentEndpoints.HasLiveReceiptAsync(db, account.Id, http.RequestAborted)) + { + return Results.Redirect(ConsentEndpoints.PathFor(http.Request.PathBase + http.Request.Path + http.Request.QueryString)); + } + + return Results.SignIn(principal, properties: null, OpenIddictScheme); + } + + /// A token whose scopes include a desk scope is a desk token. Only the chat token requires the training-notice receipt. + // Only a token whose every scope is a desk scope skips the consent gate: a client that may hold chat scopes too cannot ride a desk scope past the receipt. + private static bool IsDeskToken(ClaimsPrincipal? principal) => principal is not null && principal.GetScopes() is { Length: > 0 } scopes && scopes.All(DeskEndpoints.IsDeskScope); + + private static async Task ExchangeAsync(HttpContext http, BifrostDbContext db) + { + // Only the authorization code grant is enabled, so OpenIddict has refused every other grant already. + // The code's own principal carries the claims chosen at authorize time; suspension 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 ChatRefusalAsync(db, subject, http.RequestAborted, IsDeskToken(result.Principal)) is { } refusal) + { + return Deny(Errors.InvalidGrant, refusal); + } + + return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme); + } + + private static async Task UserInfoAsync(HttpContext http, BifrostDbContext db) + { + var result = await http.AuthenticateAsync(OpenIddictScheme); + if (!result.Succeeded || result.Principal is null) + { + return Results.Challenge(authenticationSchemes: [OpenIddictScheme]); + } + + // 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, refusal); + } + + var claims = new Dictionary { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) }; + if (result.Principal.HasScope(Scopes.Profile)) + { + claims[Claims.PreferredUsername] = result.Principal.GetClaim(Claims.PreferredUsername); + claims[Claims.Name] = result.Principal.GetClaim(Claims.Name); + } + + return Results.Json(claims); + } + + /// + /// 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 SuspendedDescription; + } + + var account = await db.UserAccounts.AsNoTracking() + .Include(x => x.Membership) + .SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken); + 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) => + Results.Forbid( + new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = error, + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description + }), + [OpenIddictScheme]); +} 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/OidcKeyMaterial.cs b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs new file mode 100644 index 0000000..8f0be75 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcKeyMaterial.cs @@ -0,0 +1,140 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Features.Oidc; + +/// A configuration fault in the OIDC provider. The message names the setting, never its value. +public sealed class OidcConfigurationException(string message) : InvalidOperationException(message); + +/// +/// The secrets the OIDC provider runs on, read once from the files names. Any +/// unreadable or wrong-shaped file stops startup; there is no fallback key. +/// +public sealed class OidcKeyMaterial : IDisposable +{ + private const string NistP256Oid = "1.2.840.10045.3.1.7"; + private const int EncryptionKeyBytes = 32; + + private readonly ECDsa _signingEcdsa; + + private OidcKeyMaterial(ECDsa signingEcdsa, SecurityKey signingKey, SecurityKey encryptionKey, string clientSecret) + { + _signingEcdsa = signingEcdsa; + SigningKey = signingKey; + EncryptionKey = encryptionKey; + ClientSecret = clientSecret; + } + + public SecurityKey SigningKey { get; } + + public SecurityKey EncryptionKey { get; } + + public string ClientSecret { get; } + + public static OidcKeyMaterial Load(OidcIssuerOptions options) + { + var mas = options.Clients.Mas; + Require(options.SigningKeyPath, "Bifrost:Oidc:SigningKeyPath"); + Require(options.EncryptionKeyPath, "Bifrost:Oidc:EncryptionKeyPath"); + Require(mas.ClientId, "Bifrost:Oidc:Clients:Mas:ClientId"); + Require(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath"); + Require(mas.RedirectUri, "Bifrost:Oidc:Clients:Mas:RedirectUri"); + // A rooted path such as "/callback" parses as an absolute file URI on Linux, so the scheme is checked too. + if (!Uri.TryCreate(mas.RedirectUri, UriKind.Absolute, out var redirect) || + redirect.Scheme is not ("https" or "http")) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI."); + } + + if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) && + (!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) || + logout.Scheme is not ("https" or "http"))) + { + throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI."); + } + + var ecdsa = LoadSigningKey(options.SigningKeyPath); + try + { + var signingKey = new ECDsaSecurityKey(ecdsa) + { + KeyId = Base64UrlEncoder.Encode(SHA256.HashData(ecdsa.ExportSubjectPublicKeyInfo())) + }; + return new OidcKeyMaterial( + ecdsa, + signingKey, + new SymmetricSecurityKey(LoadEncryptionKey(options.EncryptionKeyPath)), + ReadFile(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath").Trim()); + } + catch + { + ecdsa.Dispose(); + throw; + } + } + + public void Dispose() => _signingEcdsa.Dispose(); + + private static ECDsa LoadSigningKey(string path) + { + const string field = "Bifrost:Oidc:SigningKeyPath"; + var pem = ReadFile(path, field); + var ecdsa = ECDsa.Create(); + try + { + ecdsa.ImportFromPem(pem); + if (ecdsa.ExportParameters(false).Curve.Oid.Value != NistP256Oid) + { + throw new CryptographicException(); + } + + // ImportFromPem accepts a public key; a signing key needs the private half. + ecdsa.ExportParameters(true); + return ecdsa; + } + catch (Exception error) when (error is CryptographicException or ArgumentException) + { + ecdsa.Dispose(); + throw new OidcConfigurationException($"{field} must name a PEM file holding an EC P-256 private key."); + } + } + + private static byte[] LoadEncryptionKey(string path) + { + const string field = "Bifrost:Oidc:EncryptionKeyPath"; + try + { + var key = Convert.FromBase64String(ReadFile(path, field).Trim()); + if (key.Length == EncryptionKeyBytes) + { + return key; + } + } + catch (FormatException) + { + } + + throw new OidcConfigurationException($"{field} must name a file holding {EncryptionKeyBytes} bytes as base64."); + } + + private static string ReadFile(string path, string field) + { + try + { + return File.ReadAllText(path); + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + throw new OidcConfigurationException($"{field} must name a readable file."); + } + } + + private static void Require(string value, string field) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new OidcConfigurationException($"{field} is required when Bifrost:Oidc is configured."); + } + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs new file mode 100644 index 0000000..273acd9 --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcLogoutReconciler.cs @@ -0,0 +1,220 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +/// +/// Ends the chat sessions of a subject who is no longer allowed to chat (suspended, 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 +/// pass. It never writes membership. +/// +public sealed class OidcLogoutReconciler( + IServiceScopeFactory scopeFactory, + OidcIssuerOptions options, + OidcKeyMaterial material, + IOptions serverOptions, + IHttpClientFactory httpClients, + TimeProvider time, + ILogger logger) : BackgroundService +{ + public const string HttpClientName = "bifrost-oidc-logout"; + public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout"; + public const string LogoutTokenType = "logout+jwt"; + + public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2); + public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15); + + private readonly Dictionary _backoff = new(StringComparer.Ordinal); + + private sealed record Backoff(int Failures, DateTimeOffset NotBefore); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await ReconcileOnceAsync(stoppingToken); + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception error) + { + // The type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name); + try + { + await Task.Delay(Interval, time, stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + } + + /// One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another. + public async Task ReconcileOnceAsync(CancellationToken cancellationToken) + { + var subjects = await ListSubjectsAsync(cancellationToken); + foreach (var gone in _backoff.Keys.Except(subjects).ToArray()) + { + _backoff.Remove(gone); + } + + foreach (var subject in subjects) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await ReconcileSubjectAsync(subject, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) + { + // One subject's fault never stops the pass: its authorizations stay valid and the next tick retries it. + // The subject id and the exception type only: messages can carry connection strings or URLs. + logger.LogError("Back-channel logout for subject {Subject} failed: {ErrorType}", subject, error.GetType().Name); + } + } + } + + private async Task> ListSubjectsAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var applications = scope.ServiceProvider.GetRequiredService(); + var authorizations = scope.ServiceProvider.GetRequiredService(); + + var subjects = new HashSet(StringComparer.Ordinal); + var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken); + if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId) + { + return subjects; + } + + await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) && + await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject) + { + subjects.Add(subject); + } + } + + return subjects; + } + + private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + if (await OidcEndpoints.ChatRefusalAsync(db, subject, cancellationToken) is null) + { + _backoff.Remove(subject); + return; + } + + // A subject that recovered is forgotten at once; one still owed a logout waits out its backoff. + _backoff.TryGetValue(subject, out var previous); + if (previous is not null && time.GetUtcNow() < previous.NotBefore) + { + return; + } + + var failures = (previous?.Failures ?? 0) + 1; + var status = await SendLogoutAsync(subject, cancellationToken); + if (status is { } code && (int)code is >= 200 and < 300) + { + var authorizations = scope.ServiceProvider.GetRequiredService(); + await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken)) + { + if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken)) + { + await authorizations.TryRevokeAsync(authorization, cancellationToken); + } + } + + _backoff.Remove(subject); + return; + } + + // 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them. + var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound; + var delay = fault ? MaxBackoff : BackoffFor(failures); + _backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay); + logger.LogWarning( + "Back-channel logout to MAS failed with {Status}; retrying in {Delay}", + status is { } s ? (int)s : "no response", + delay); + } + + internal static TimeSpan BackoffFor(int failures) + { + var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1); + return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds)); + } + + /// The status MAS answered, or null on a network fault or timeout. The response body is never read. + private async Task SendLogoutAsync(string subject, CancellationToken cancellationToken) + { + var token = BuildLogoutToken(subject); + using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri) + { + Content = new FormUrlEncodedContent([new KeyValuePair("logout_token", token)]) + }; + try + { + using var response = await httpClients.CreateClient(HttpClientName) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + return response.StatusCode; + } + catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested) + { + return null; + } + } + + /// + /// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp + /// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub). + /// + private string BuildLogoutToken(string subject) + { + var now = time.GetUtcNow(); + var descriptor = new SecurityTokenDescriptor + { + TokenType = LogoutTokenType, + Issuer = serverOptions.Value.Issuer!.AbsoluteUri, + Audience = options.Clients.Mas.ClientId, + IssuedAt = now.UtcDateTime, + Expires = now.UtcDateTime + TokenLifetime, + SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256), + Claims = new Dictionary + { + [Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject, + [Claims.JwtId] = Guid.NewGuid().ToString("D"), + ["events"] = new Dictionary { [LogoutEvent] = new Dictionary() } + } + }; + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs new file mode 100644 index 0000000..78526af --- /dev/null +++ b/src/Bifrost.Web/Features/Oidc/OidcServiceCollectionExtensions.cs @@ -0,0 +1,84 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Microsoft.Extensions.Options; +using OpenIddict.Abstractions; +using OpenIddict.Server; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace Bifrost.Web.Features.Oidc; + +public static class OidcServiceCollectionExtensions +{ + public const string AuthorizationPath = "/connect/authorize"; + public const string TokenPath = "/connect/token"; + public const string UserInfoPath = "/connect/userinfo"; + + /// How long a parked authorize request (OpenIddict's request token) can wait for the user to sign in. + private static readonly TimeSpan ParkedRequestLifetime = TimeSpan.FromMinutes(10); + + /// + /// Registers Bifrost as an OIDC provider whose issuer is : authorization-code flow with + /// PKCE, ES256 id_tokens, scopes openid and profile, one configured client. Keys come from files, never from + /// generated development keys. The authorize request is parked by OpenIddict's own request caching, so the + /// sign-in return path carries only a short request_uri. + /// + public static IServiceCollection AddBifrostOidc(this IServiceCollection services, OidcIssuerOptions options) + { + var material = OidcKeyMaterial.Load(options); + services.AddSingleton(material); + services.AddSingleton(options); + + services.AddOpenIddict() + .AddCore(core => core.UseEntityFrameworkCore().UseDbContext()) + .AddServer(server => + { + server.SetAuthorizationEndpointUris(AuthorizationPath) + .SetTokenEndpointUris(TokenPath) + .SetUserInfoEndpointUris(UserInfoPath); + + server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange(); + server.RegisterScopes(Scopes.OpenId, Scopes.Profile); + server.RegisterClaims(Claims.Subject, Claims.PreferredUsername, Claims.Name); + server.EnableAuthorizationRequestCaching(); + server.Configure(opts => + { + opts.ClientAuthenticationMethods.Clear(); + opts.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic); + opts.RequestTokenLifetime = ParkedRequestLifetime; + }); + + server.AddSigningKey(material.SigningKey); + server.AddEncryptionKey(material.EncryptionKey); + + server.UseAspNetCore() + .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough() + .EnableUserInfoEndpointPassthrough(); + }); + + // The issuer is the public host, read from the bound options so it has one source. + services.AddOptions().Configure>((server, host) => + { + if (!Uri.TryCreate(host.Value.PublicBaseUrl, UriKind.Absolute, out var issuer)) + { + throw new OidcConfigurationException("Host:PublicBaseUrl is required when Bifrost:Oidc is configured."); + } + + server.Issuer = issuer; + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); + services.AddHostedService(); + if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri)) + { + // MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx. + services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false }); + services.AddHostedService(); + } + + return services; + } +} 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/BifrostCookies.cs b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs new file mode 100644 index 0000000..1c099e8 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/BifrostCookies.cs @@ -0,0 +1,17 @@ +namespace Bifrost.Web.Features.Shared; + +// Every cookie Bifrost sets carries the __Host- prefix: the browser then accepts it only with Secure, Path=/ and no +// Domain, so no sibling subdomain of gamecult.org (the preview host is same-site) can plant or shadow one by setting +// a Domain=gamecult.org cookie of the same name. The prefix forbids a narrower Path, so these all live at Path=/. +public static class BifrostCookies +{ + public const string HostPrefix = "__Host-"; + + public const string Session = HostPrefix + "bifrost_session"; + public const string OAuthCorrelation = HostPrefix + "bifrost_oauth_correlation"; + public const string Antiforgery = HostPrefix + "bifrost_antiforgery"; + public const string Voter = HostPrefix + "bifrost_voter"; + public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_"; + + public const string Path = "/"; +} 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 9f07cf9..0837f7e 100644 --- a/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs +++ b/src/Bifrost.Web/Features/Shared/DashboardSnapshotService.cs @@ -29,6 +29,7 @@ public sealed class DashboardSnapshotService(BifrostDbContext dbContext, TimePro var openMotions = await dbContext.Motions .AsNoTracking() .Include(x => x.Votes) + .Where(x => x.Kind == MotionKind.Standard) .OrderBy(x => x.ClosesAtUtc) .Take(5) .Select(x => new DashboardMotion( @@ -91,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/FixedWindowBudget.cs b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs new file mode 100644 index 0000000..b6c9083 --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/FixedWindowBudget.cs @@ -0,0 +1,131 @@ +namespace Bifrost.Web.Features.Shared; + +// Why a permit was refused and how long until the answer can change. TableFull is not the caller's own spending: the +// table holds only spent windows and has no room for a key it does not know. +public readonly record struct BudgetDenial(TimeSpan RetryAfter, bool TableFull); + +// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected +// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a +// window. Windows all have one length, so they end in the order they began: a list of entries in start order expires +// them from the front and the table never holds a key past its window. +// The table holds at most maxKeys keys. A new key arriving at the cap evicts the least recently used key that still +// has permits left (nginx limit_req_zone does the same); a key that has spent all its permits is never evicted, so a +// rotating caller can at most win back a forgotten key's partial budget and never a spent one, and nobody fresh is +// refused because the table is full. Only when every held key is spent is a new key refused, with the true wait. +// Every step is O(1): two linked lists, one by window start and one by last use among keys with permits left. +// System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a +// test cannot move. +public sealed class FixedWindowBudget +{ + private readonly int permits; + private readonly TimeSpan window; + private readonly int maxKeys; + private readonly TimeProvider clock; + private readonly long origin; + private readonly Dictionary entries = new(StringComparer.Ordinal); + private readonly LinkedList byStart = new(); + private readonly LinkedList byUse = new(); + private readonly object gate = new(); + + public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock) + { + ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero); + ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1); + this.permits = permits; + this.window = window; + this.maxKeys = maxKeys; + this.clock = clock; + origin = clock.GetTimestamp(); + } + + // How many keys are held now: the keys whose window is still open, never more than maxKeys. + public int TrackedKeys + { + get + { + lock (gate) + { + return entries.Count; + } + } + } + + // Takes one permit for the key and returns null, or returns why it could not. + public BudgetDenial? Spend(string key) + { + lock (gate) + { + var now = clock.GetElapsedTime(origin); + while (byStart.First is { Value: var oldest } && oldest.Start + window <= now) + { + Remove(oldest); + } + + if (entries.TryGetValue(key, out var entry)) + { + if (entry.Count >= permits) + { + return new BudgetDenial(entry.Start + window - now, false); + } + + entry.Count++; + if (entry.Count >= permits) + { + byUse.Remove(entry.UseNode!); + entry.UseNode = null; + } + else + { + byUse.Remove(entry.UseNode!); + byUse.AddLast(entry.UseNode!); + } + + return null; + } + + if (entries.Count >= maxKeys) + { + if (byUse.First is not { Value: var forgotten }) + { + return new BudgetDenial(byStart.First!.Value.Start + window - now, true); + } + + Remove(forgotten); + } + + var added = new Entry(key, now); + entries[key] = added; + added.StartNode = byStart.AddLast(added); + if (added.Count < permits) + { + added.UseNode = byUse.AddLast(added); + } + + return null; + } + } + + private void Remove(Entry entry) + { + entries.Remove(entry.Key); + byStart.Remove(entry.StartNode!); + if (entry.UseNode is not null) + { + byUse.Remove(entry.UseNode); + } + } + + private sealed class Entry(string key, TimeSpan start) + { + public string Key { get; } = key; + + public TimeSpan Start { get; } = start; + + public int Count { get; set; } = 1; + + public LinkedListNode? StartNode { get; set; } + + public LinkedListNode? UseNode { get; set; } + } +} diff --git a/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs new file mode 100644 index 0000000..1de135b --- /dev/null +++ b/src/Bifrost.Web/Features/Shared/LocalReturnPath.cs @@ -0,0 +1,34 @@ +namespace Bifrost.Web.Features.Shared; + +/// +/// A sign-in return target that stays on this site. The only way to make one is , which +/// accepts a path of at most characters that starts with a single slash and holds no +/// control character, and otherwise yields . Callers parse once at entry and carry the typed value from there. +/// +public sealed record LocalReturnPath +{ + private LocalReturnPath(string value) => Value = value; + + /// Bounds the path so the attempt cookie that carries it stays far below a browser's 4096-byte limit. + public const int MaxLength = 256; + + public static LocalReturnPath Default { get; } = new("/App"); + + public string Value { get; } + + public static LocalReturnPath Parse(string? candidate) + { + if (string.IsNullOrEmpty(candidate) || + candidate.Length > MaxLength || + candidate[0] != '/' || + candidate.Any(char.IsControl) || + (candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\'))) + { + return Default; + } + + return new LocalReturnPath(candidate); + } + + public override string ToString() => Value; +} 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 41b1147..58fe2fb 100644 --- a/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs +++ b/src/Bifrost.Web/Features/Shared/StartupConfigurationValidator.cs @@ -1,4 +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; @@ -10,16 +12,77 @@ public sealed class StartupConfigurationValidator( IOptions gitHubAppOptions, IOptions heimdallOptions, IOptions stripeOptions, - IOptions hostOptions) + IOptions hostOptions, + IOptions conceptVoteOptions, + IOptions deskOptions, + IOptions registrationOptions, + OidcIssuerOptions? oidcOptions = null) { public void Validate() { - if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing")) + 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)) { - return; + failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}."); } - var failures = new List(); + if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits)) + { + failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}."); + } + + if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive."); + } + + if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero) + { + failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive."); + } + + if (conceptVotes.MaxTrackedKeys < 1) + { + 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); + return; + } if (!gitHubOAuthOptions.Value.IsConfigured) { @@ -42,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) @@ -66,10 +136,146 @@ public sealed class StartupConfigurationValidator( failures.Add("Connection string 'Bifrost' must be configured."); } + if (string.IsNullOrWhiteSpace(configuration["DataProtection:KeysPath"])) + { + failures.Add("DataProtection:KeysPath must be configured for production."); + } + + if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) || + conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength) + { + failures.Add( + $"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production."); + } + + if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin)) + { + failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash."); + } + + 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) { throw new InvalidOperationException( "Bifrost startup configuration is incomplete: " + string.Join(" ", failures)); } } + + // The authority form is the canonical spelling, so a wildcard, path, query, upper-case host or default port + // differs from it and is refused; user info is part of the authority, so it is refused on its own. + private static bool IsExactHttpsOrigin(string? origin) => + Uri.TryCreate(origin, UriKind.Absolute, out var uri) && + uri.Scheme == Uri.UriSchemeHttps && + uri.UserInfo.Length == 0 && + string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal); } 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 new file mode 100644 index 0000000..d9e67f2 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml @@ -0,0 +1,56 @@ +@page "/mill/{collection}/{key}" +@model Bifrost.Web.Pages.Mill.VoteModel +@{ + ViewData["Title"] = Model.Subject.Title; + var stance = Model.Subject.YourStance; +} + +
+ Back to the Concept Mill +

@Model.Subject.Title

+ +
+
Everyone
+
@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down
+
Members
+
@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down
+
+ +

+ @if (stance is null) + { + You have not voted on this concept. + } + else + { + Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down"). + } +

+ +
+
+ +
+
+ +
+ @if (stance is not null) + { +
+ +
+ } +
+ + @if (Model.SignedIn) + { +

Your vote is kept against your Bifrost account. No cookie is set.

+ } + else + { +

+ Voting saves one small cookie on this device. Its only purpose is to keep your one vote yours so you can + change or retract it; it lasts a year and carries nothing else. +

+ } +
diff --git a/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs new file mode 100644 index 0000000..41ed934 --- /dev/null +++ b/src/Bifrost.Web/Pages/Mill/Vote.cshtml.cs @@ -0,0 +1,179 @@ +using Bifrost.Web.Configuration; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Pages.Mill; + +// The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the +// voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads. +[IgnoreAntiforgeryToken] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class VoteModel( + ICurrentBifrostActorAccessor actorAccessor, + ConceptAppraisalService appraisals, + VoterKeyResolver deviceCookie, + ConceptVoteRateLimits limits, + IOptions options, + IOptions host) : PageModel +{ + public ConceptSubjectView Subject { get; private set; } = null!; + + public bool SignedIn { get; private set; } + + public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key); + + // 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 (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; + if (context.HandlerMethod is null) + { + context.Result = new StatusCodeResult( + HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsPost(method) + ? StatusCodes.Status400BadRequest + : StatusCodes.Status405MethodNotAllowed); + return; + } + + // The handler the request names must be the handler selected: GET ?handler=Up has no OnGetUp to run. + var named = context.HttpContext.Request.Query["handler"].ToString(); + if (string.IsNullOrEmpty(named) != string.IsNullOrEmpty(context.HandlerMethod.Name)) + { + context.Result = new StatusCodeResult(StatusCodes.Status400BadRequest); + return; + } + + if (HttpMethods.IsPost(method) && !RequestOriginGuard.Allows(context.HttpContext.Request, host.Value)) + { + context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden); + return; + } + + await next(); + } + + public async Task OnGetAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); + SignedIn = voter?.UserAccountId is not null; + return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound(); + } + + public Task OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Up, cancellationToken); + + public Task OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) => + CastAsync(collection, key, ConceptStance.Down, cancellationToken); + + public async Task OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var voter = ResolveVoter(actor); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal, voter) is { } throttled) + { + return throttled; + } + + await appraisals.RetractAsync(collection, key, voter, cancellationToken); + return RedirectToPage(new { collection, key }); + } + + private async Task CastAsync( + string collection, + string key, + ConceptStance stance, + CancellationToken cancellationToken) + { + var actor = await actorAccessor.GetAsync(cancellationToken); + var client = HttpContext.Connection.RemoteIpAddress; + var voter = ResolveVoter(actor); + if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null) + { + return NotFound(); + } + + if (Throttled(limits.DenyCast(client, voter), CastRefusal, voter) is { } throttled) + { + return throttled; + } + + if (voter is null) + { + if (Throttled(limits.DenyMint(client), MintRefusal, voter) is { } mintThrottled) + { + return mintThrottled; + } + + voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext)); + } + + var result = await appraisals.CastAsync( + collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken); + return result is CastResult.UnknownSubject or CastResult.RetiredSubject + ? NotFound() + : RedirectToPage(new { collection, key }); + } + + // Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that + // unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does. + private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) => + VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext)); + + // A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and + // repeats nothing of the request. The subject is checked before any budget is spent, so a request for a concept that + // does not exist costs the sender nothing and the table nothing. A refused new device is told it can sign in, which + // has a budget of its own. A table full of spent windows is not the visitor's doing and says so. + private const string CastRefusal = "You are voting too fast. Please wait a little before voting again."; + + private const string MintRefusal = + "Too many new voters have voted from your network recently. You can sign in to vote now, or try again later."; + + private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while."; + + // Accounts have their own table, so a visitor who is not signed in can still vote by signing in while a table is full. + private const string BusyRefusalSignIn = + "Voting is very busy right now. You can sign in to vote now, or try again in a little while."; + + private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal, VoterIdentity? voter) + { + if (denial is not { } refused) + { + return null; + } + + var busy = voter?.UserAccountId is null ? BusyRefusalSignIn : BusyRefusal; + var refusal = refused.TableFull ? busy : spentRefusal; + Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + return new ContentResult + { + StatusCode = StatusCodes.Status429TooManyRequests, + ContentType = "text/plain; charset=utf-8", + Content = refusal + }; + } + + private async Task LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken) + { + var view = await appraisals.GetSubjectAsync(collection, key, voter, cancellationToken); + if (view is null) + { + return false; + } + + Subject = view; + return true; + } +} 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 695b478..ef92be8 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -6,20 +6,43 @@ using Bifrost.Web.Configuration; 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; using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpLogging; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using System.Net; + +// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations. +if (args.Length > 0 && args[0] == "concepts") +{ + var host = Host.CreateApplicationBuilder([]); + host.Services.AddDbContext(options => options.UseNpgsql(BifrostConnectionString(host.Configuration))); + host.Services.AddSingleton(TimeProvider.System); + host.Services.AddScoped(); + host.Services.AddScoped(); + using var commandHost = host.Build(); + Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out); + return; +} + +static string BifrostConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("Bifrost") + ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; var builder = WebApplication.CreateBuilder(args); @@ -41,7 +64,8 @@ builder.Services builder.Services .AddOptions() - .BindConfiguration(HeimdallOptions.SectionName); + .BindConfiguration(HeimdallOptions.SectionName) + .PostConfigure>(HeimdallOptions.ResolveAppSecret); builder.Services .AddOptions() @@ -51,10 +75,26 @@ builder.Services .AddOptions() .BindConfiguration(BridgeOptions.SectionName); +builder.Services + .AddOptions() + .BindConfiguration(ConceptVoteOptions.SectionName); + +builder.Services + .AddOptions() + .BindConfiguration(DeskOptions.SectionName); + +builder.Services.AddAntiforgery(options => +{ + options.Cookie.Name = BifrostCookies.Antiforgery; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.Path = BifrostCookies.Path; +}); + builder.Services.AddRazorPages(options => { options.Conventions.AuthorizePage("/Membership/Status"); options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName); + options.Conventions.AllowAnonymousToFolder("/Mill"); }); builder.Services.ConfigureHttpJsonOptions(options => { @@ -63,10 +103,7 @@ builder.Services.ConfigureHttpJsonOptions(options => builder.Services.AddDbContext(options => { - var connectionString = builder.Configuration.GetConnectionString("Bifrost") - ?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres"; - - options.UseNpgsql(connectionString); + options.UseNpgsql(BifrostConnectionString(builder.Configuration)); }); builder.Services.AddHttpLogging(options => @@ -82,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(); @@ -91,33 +129,60 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +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(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpClient((serviceProvider, client) => -{ - var options = serviceProvider.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/')); -}); + HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)) + .RedactLoggedHeaders(HeimdallAuthService.RedactsHeader); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) => + HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService>().Value)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.stripe.com/"); }); +var dataProtectionBuilder = builder.Services + .AddDataProtection() + .SetApplicationName("Bifrost"); var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"]; if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath)) { - builder.Services - .AddDataProtection() - .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)); + dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)); } +// nginx on this host is the only proxy, and it connects from loopback. Trusting exactly +// those addresses keeps a direct client from choosing its own address with X-Forwarded-For. +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + options.KnownProxies.Add(IPAddress.Loopback); + options.KnownProxies.Add(IPAddress.IPv6Loopback); + options.KnownProxies.Add(IPAddress.Loopback.MapToIPv6()); + options.ForwardLimit = 1; +}); + var configuredGitHubOAuthOptions = builder.Configuration .GetSection(GitHubOAuthOptions.SectionName) .Get() ?? new GitHubOAuthOptions(); @@ -126,6 +191,10 @@ var authenticationBuilder = builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { + options.Cookie.Name = BifrostCookies.Session; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.Path = BifrostCookies.Path; + options.Cookie.Domain = null; options.LoginPath = "/auth/sign-in"; options.AccessDeniedPath = "/Membership/Status"; options.SlidingExpiration = true; @@ -142,6 +211,9 @@ if (configuredGitHubOAuthOptions.IsConfigured) options.TokenEndpoint = "https://github.com/login/oauth/access_token"; options.UserInformationEndpoint = "https://api.github.com/user"; options.SaveTokens = true; + options.CorrelationCookie.Name = BifrostCookies.OAuthCorrelation; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.CorrelationCookie.Path = BifrostCookies.Path; options.Scope.Add("read:user"); options.Scope.Add("user:email"); options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); @@ -183,6 +255,22 @@ builder.Services.AddAuthorization(options => }); }); +builder.Services.AddConceptTallyCors(); + +// The OIDC provider is mapped only when Bifrost:Oidc is configured; a configured provider that cannot load its keys stops startup. +var oidcOptions = OidcIssuerOptions.FromConfiguration(builder.Configuration); +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(); @@ -194,6 +282,8 @@ await using (var scope = app.Services.CreateAsyncScope()) .RunAsync(CancellationToken.None); } +app.UseForwardedHeaders(); + if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); @@ -202,8 +292,14 @@ 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(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); @@ -220,6 +316,19 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke statusCode: StatusCodes.Status503ServiceUnavailable); }).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, @@ -253,22 +362,18 @@ app.MapPost("/github/webhooks", async ( app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions gitHubOptions) => { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var provider = httpContext.Request.Query["provider"].ToString(); if (string.IsNullOrWhiteSpace(provider)) { - await WriteSignInChooserAsync(httpContext, returnUrl); + await WriteSignInChooserAsync(httpContext, returnPath.Value); return; } if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase)) { - httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}"); + httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}"); return; } @@ -280,56 +385,190 @@ 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, HeimdallAuthService heimdallAuthService, IOptions hostOptions, + ILogger logger, CancellationToken cancellationToken) => { try { - var returnUrl = httpContext.Request.Query["returnUrl"].ToString(); - if (string.IsNullOrWhiteSpace(returnUrl)) - { - returnUrl = "/App"; - } - + var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString()); var requireMemberAccess = string.Equals( httpContext.Request.Query["access"].ToString(), "member", @@ -338,30 +577,18 @@ app.MapGet("/auth/heimdall/{provider}", async ( var start = await heimdallAuthService.StartAsync( provider, hostOptions.Value.PublicBaseUrl, - returnUrl, + returnPath, requireMemberAccess, + httpContext.Request.Cookies, cancellationToken); + httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options()); return Results.Redirect(start.AuthorizationUrl.ToString()); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); - } -}).AllowAnonymous(); - -app.MapPost("/auth/heimdall/callback", ( - HeimdallBackendCallbackPayload payload, - HeimdallAuthService heimdallAuthService) => -{ - try - { - heimdallAuthService.StoreBackendCallback(payload); - return Results.Accepted(); - } - catch (HeimdallAuthException error) - { - return Results.BadRequest(error.Message); + logger.LogWarning("Heimdall start refused: {Reason}", error.Message); + return Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -487,31 +714,165 @@ eveGovernance.MapGet("/surface", async ( "application/json"); }); -app.MapGet("/auth/heimdall/wait", async ( - HttpContext httpContext, - HeimdallAuthService heimdallAuthService, - CancellationToken cancellationToken) => -{ - var attemptId = httpContext.Request.Query["attemptId"].ToString(); - if (string.IsNullOrWhiteSpace(attemptId)) - { - return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id"); - } +// Sealed-ballot intake. Typed JSON documents posted directly by the voter; no ballot content ever rides in a room message, +// and no response before close carries another member's ballot. +var sealedMotions = app.MapGroup("/api/sealed-motions") + .RequireAuthorization(ActiveMemberRequirement.PolicyName); +async Task SealedCall(Func> call) +{ try { - var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken); - await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); - return Results.Redirect(signIn.RedirectUri); + return Results.Text(JsonSerializer.Serialize(await call(), appJsonSerializerOptions), "application/json"); } - catch (HeimdallAuthPendingException) + catch (SealedBallotException error) { - httpContext.Response.Headers.Append("Refresh", "2"); - return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain"); + return Results.Text( + JsonSerializer.Serialize(new { error = error.Message }, appJsonSerializerOptions), + "application/json", + statusCode: error.Failure switch + { + SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden, + SealedBallotFailure.Unauthorized => StatusCodes.Status401Unauthorized, + SealedBallotFailure.NotFound => StatusCodes.Status404NotFound, + SealedBallotFailure.WrongPhase => StatusCodes.Status409Conflict, + _ => StatusCodes.Status400BadRequest + }); + } +} + +sealedMotions.MapPost("", (ICurrentBifrostActorAccessor actors, SealedBallotService service, OpenSealedMotionCommand command, CancellationToken ct) => + SealedCall(async () => await service.OpenAsync(await actors.GetAsync(ct), command, ct))); +sealedMotions.MapGet("/{id:guid}", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) => + SealedCall(async () => await service.GetStateAsync(await actors.GetAsync(ct), id, ct))); +sealedMotions.MapPost("/{id:guid}/commit", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedCommitRequest body, CancellationToken ct) => + SealedCall(async () => await service.CommitAsync(await actors.GetAsync(ct), id, body.Commitment, ct))); +sealedMotions.MapPost("/{id:guid}/reveal", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, SealedBallotDocument body, CancellationToken ct) => + SealedCall(async () => await service.RevealAsync(await actors.GetAsync(ct), id, body, ct))); +sealedMotions.MapGet("/{id:guid}/result", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) => + SealedCall(async () => await service.GetResultAsync(await actors.GetAsync(ct), id, ct))); + +sealedMotions.MapPost("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, RegisterMindKeyRequest body, CancellationToken ct) => + SealedCall(async () => await service.RegisterKeyAsync(await actors.GetAsync(ct), body.Persona, body.UserAccountId, body.PublicKey, ct))); +sealedMotions.MapGet("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, CancellationToken ct) => + SealedCall(async () => await service.ListKeysAsync(await actors.GetAsync(ct), ct))); +sealedMotions.MapDelete("/mind-keys/{keyId:guid}", (ICurrentBifrostActorAccessor actors, MindIntakeService service, Guid keyId, CancellationToken ct) => + SealedCall(async () => + { + await service.RevokeKeyAsync(await actors.GetAsync(ct), keyId, ct); + return new { revoked = keyId }; + })); + +// Signed Mind intake: the Ed25519 signature is the credential. No session, no cookie; the verified envelope runs the same +// SealedBallotService verbs as a member cast. +app.MapPost("/api/sealed-motions/{id:guid}/mind", async ( + HttpContext http, MindIntakeService service, MindIntakeRateLimit limiter, IOptions limits, Guid id, CancellationToken ct) => +{ + // Per-source permit and a hard body cap, both before any parsing or database work. + if (limiter.Spend(http.Connection.RemoteIpAddress?.ToString() ?? "unknown") is { } denied) + { + http.Response.Headers.RetryAfter = Math.Ceiling(denied.RetryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture); + return Results.Text("{\"error\":\"Too many requests.\"}", "application/json", statusCode: StatusCodes.Status429TooManyRequests); + } + + var max = limits.Value.MaxBodyBytes; + if (http.Request.ContentLength > max) + { + return Results.Text("{\"error\":\"Envelope too large.\"}", "application/json", statusCode: StatusCodes.Status413PayloadTooLarge); + } + + var buffer = new byte[max + 1]; + var read = 0; + int n; + while (read < buffer.Length && (n = await http.Request.Body.ReadAsync(buffer.AsMemory(read), ct)) > 0) + { + read += n; + } + + if (read > max) + { + return Results.Text("{\"error\":\"Envelope too large.\"}", "application/json", statusCode: StatusCodes.Status413PayloadTooLarge); + } + + MindEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(buffer.AsSpan(0, read), new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + catch (JsonException) + { + envelope = null; + } + + return envelope is null + ? Results.Text("{\"error\":\"The envelope is malformed.\"}", "application/json", statusCode: StatusCodes.Status400BadRequest) + : await SealedCall(async () => await service.HandleAsync(id, envelope, ct)); +}).AllowAnonymous(); + +app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => +{ + httpContext.Response.Headers.CacheControl = "no-store"; + httpContext.Response.Headers["Referrer-Policy"] = "no-referrer"; + return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8); +}).AllowAnonymous(); + +app.MapPost("/auth/heimdall/complete", async ( + HttpContext httpContext, + HeimdallAuthService heimdallAuthService, + IOptions hostOptions, + ILogger logger, + CancellationToken cancellationToken) => +{ + 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, + held?.Value, + form?["code"].ToString(), + cancellationToken); + await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal); + httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + return Results.LocalRedirect(signIn.ReturnPath.Value); } catch (HeimdallAuthException error) { - return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}"); + logger.LogWarning("Heimdall completion refused: {Reason}", error.Message); + if (held is not null) + { + httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions()); + } + + return linking + ? Results.LocalRedirect(HeimdallLinkOutcomes.Redirect(HeimdallLinkOutcome.Error)) + : Results.Redirect(HeimdallAuthService.ErrorRedirect); } }).AllowAnonymous(); @@ -531,6 +892,53 @@ static IResult JsonResult(T value, JsonSerializerOptions options, int statusC "application/json", statusCode: statusCode); +static async Task ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken) +{ + if (!request.HasFormContentType) + { + return null; + } + + try + { + return await request.ReadFormAsync(cancellationToken); + } + catch (Exception error) when (error is InvalidDataException or BadHttpRequestException) + { + return null; + } +} + +// 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"; @@ -584,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 b24dd83..09b3310 100644 --- a/src/Bifrost.Web/appsettings.json +++ b/src/Bifrost.Web/appsettings.json @@ -27,6 +27,8 @@ "Heimdall": { "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 new file mode 100644 index 0000000..d969a56 --- /dev/null +++ b/src/Bifrost.Web/packages.lock.json @@ -0,0 +1,494 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "BouncyCastle.Cryptography": { + "type": "Direct", + "requested": "[2.4.0, )", + "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, )", + "resolved": "8.0.0", + "contentHash": "94reKYu63jg4O75UI3LMJHwOSi8tQ6IfubiZhdnSsWcgtmAuF8OyLfjK/MIxuvaQRJZAF6E747FIuxjOtb8/og==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp.Workspaces": "4.5.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Microsoft.Extensions.DependencyModel": "8.0.0", + "Mono.TextTemplating": "2.2.1" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Npgsql": { + "type": "Direct", + "requested": "[8.0.3, )", + "resolved": "8.0.3", + "contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0" + } + }, + "Npgsql.EntityFrameworkCore.PostgreSQL": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "GDXiMS9peEdjSCU/rfgyHruio7q6tYuywGaktqEi6UPQ6ILechp3fVVX+dHXkIXt4nklCBzYVWkzFrSL9ubKUA==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.0", + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.0", + "Microsoft.EntityFrameworkCore.Relational": "8.0.0", + "Npgsql": "8.0.0" + } + }, + "OpenIddict.EntityFrameworkCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "U+Lq0DuEm9bolWOVur0Qy3LgwzCSjRQKs2Q8tUfFfpuAJsnhgK7j401eDm5PdHV1wnoaBYhXEMkkW+UKeADyaw==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Relational": "8.0.30", + "OpenIddict.Core": "7.7.1", + "OpenIddict.EntityFrameworkCore.Models": "7.7.1" + } + }, + "OpenIddict.Server.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==", + "dependencies": { + "OpenIddict.Server": "7.7.1" + } + }, + "OpenIddict.Validation.AspNetCore": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "9Gpw13k6KRif6LCeIfzS5cfDPxzEvg5I4l8JKwMxNL4mqal8eT00T069RnuXuDY8CwOO43FLD55JY7Wh3an5cg==", + "dependencies": { + "OpenIddict.Validation": "7.7.1" + } + }, + "OpenIddict.Validation.ServerIntegration": { + "type": "Direct", + "requested": "[7.7.1, )", + "resolved": "7.7.1", + "contentHash": "tZd5oqlVHftWjo3gthwXRvDE09NGmOjo5yjyRH8W2q4Yw9Hntkfi70+T2ESFKJ++oqueQT4YG5ilyot/o0OBMA==", + "dependencies": { + "OpenIddict.Server": "7.7.1", + "OpenIddict.Validation": "7.7.1" + } + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[8.0.5, )", + "resolved": "8.0.5", + "contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg==" + }, + "Humanizer.Core": { + "type": "Transitive", + "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", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Bcl.Cryptography": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==", + "dependencies": { + "System.Formats.Asn1": "10.0.2" + } + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.3", + "contentHash": "j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "lwAbIZNdnY0SUNoDmZHkVUwLO8UyNnyyh1t/4XsbFxi4Ounb3xszIYZaWhyj5ZjyfcwqwmtMbE7fUTVCqQEIdQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.3", + "System.Collections.Immutable": "6.0.0", + "System.Reflection.Metadata": "6.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.0.0", + "System.Text.Encoding.CodePages": "6.0.0" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "cM59oMKAOxvdv76bdmaKPy5hfj+oR+zxikWoueEB7CwTko7mt9sVKZI8Qxlov0C/LuKEG+WQwifepqL3vuTiBQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.CSharp.Workspaces": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "h74wTpmGOp4yS4hj+EvNzEiPgg/KVs2wmSfTZ81upJZOtPkJsVkgfsgtxxqmAeapjT/vLKfmYV0bS8n5MNVP+g==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.CodeAnalysis.CSharp": "[4.5.0]", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "Microsoft.CodeAnalysis.Workspaces.Common": "[4.5.0]" + } + }, + "Microsoft.CodeAnalysis.Workspaces.Common": { + "type": "Transitive", + "resolved": "4.5.0", + "contentHash": "l4dDRmGELXG72XZaonnOeORyD/T5RpEu5LGHOUIhnv+MmUWDY/m1kWXGwtcgQ5CJ5ynkFiRnIYzTKXYjUs7rbw==", + "dependencies": { + "Humanizer.Core": "2.14.1", + "Microsoft.Bcl.AsyncInterfaces": "6.0.0", + "Microsoft.CodeAnalysis.Common": "[4.5.0]", + "System.Composition": "6.0.0", + "System.IO.Pipelines": "6.0.3", + "System.Threading.Channels": "6.0.0" + } + }, + "Microsoft.EntityFrameworkCore": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==", + "dependencies": { + "Microsoft.EntityFrameworkCore.Abstractions": "8.0.30", + "Microsoft.EntityFrameworkCore.Analyzers": "8.0.30", + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1" + } + }, + "Microsoft.EntityFrameworkCore.Abstractions": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng==" + }, + "Microsoft.EntityFrameworkCore.Analyzers": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg==" + }, + "Microsoft.EntityFrameworkCore.Relational": { + "type": "Transitive", + "resolved": "8.0.30", + "contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==", + "dependencies": { + "Microsoft.EntityFrameworkCore": "8.0.30", + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.DependencyModel": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==", + "dependencies": { + "System.Text.Encodings.Web": "8.0.0", + "System.Text.Json": "8.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "8.0.1", + "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "8.0.1", + "Microsoft.Extensions.Logging.Abstractions": "8.0.2", + "Microsoft.Extensions.Options": "8.0.2" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "8.19.2", + "contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==", + "dependencies": { + "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", + "contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==", + "dependencies": { + "Microsoft.Bcl.Cryptography": "10.0.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "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", + "contentHash": "KZYeKBET/2Z0gY1WlTAK7+RHTl7GSbtvTLDXEZZojUdAPqpQNDL6tHv7VUpqfX5VEOh+uRGKaZXkuD253nEOBQ==", + "dependencies": { + "System.CodeDom": "4.4.0" + } + }, + "OpenIddict.Abstractions": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "dgb+2dGHzwjVUs7Jc8Dt3PQurVCEllX8XW8+WPmdB1eV5IKRoAZyiJyp3mgFOR1sk/jvilPqnMzQYGkVNVf66Q==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Microsoft.Extensions.Primitives": "8.0.0", + "Microsoft.IdentityModel.Tokens": "8.19.2" + } + }, + "OpenIddict.Core": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "TLk8u4zZ9IvTpTYV+a6nX1eBpnPR5E3seMPGcArXhfBK0VTImWWzPbcdx+GxiPxyq7+8iOg05uIJt7FsRwDpZg==", + "dependencies": { + "Microsoft.Extensions.Caching.Memory": "8.0.1", + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.Extensions.Options": "8.0.2", + "OpenIddict.Abstractions": "7.7.1" + } + }, + "OpenIddict.EntityFrameworkCore.Models": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "DjJ5/7TBV7q7zB43v7em73nQBIacid5hxtlZzLnuWBg7kzvlBlUvvmgEhW/asoMBT3sR2nEyufB0wKOnn1niXQ==" + }, + "OpenIddict.Server": { + "type": "Transitive", + "resolved": "7.7.1", + "contentHash": "Se5+zNefCR9VpRPudmXOVJ6z79rSdEx+PvlPn1fGvKhLW6cqT5w8q8lPdRKe+zdiyAEXpSg+K5m6lTSpVqYOWA==", + "dependencies": { + "Microsoft.Extensions.Logging": "8.0.1", + "Microsoft.IdentityModel.JsonWebTokens": "8.19.2", + "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", + "contentHash": "2sCCb7doXEwtYAbqzbF/8UAeDRMNmPaQbU2q50Psg1J9KzumyVVCgKQY8s53WIPTufNT0DpSe9QRvVjOzfDWBA==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Composition": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "d7wMuKQtfsxUa7S13tITC8n1cQzewuhD5iDjZtK2prwFfKVzdYtgrTHgjaV03Zq7feGQ5gkP85tJJntXwInsJA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Convention": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0", + "System.Composition.TypedParts": "6.0.0" + } + }, + "System.Composition.AttributedModel": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "WK1nSDLByK/4VoC7fkNiFuTVEiperuCN/Hyn+VN30R+W2ijO1d0Z2Qm0ScEl9xkSn1G2MyapJi8xpf4R8WRa/w==" + }, + "System.Composition.Convention": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "XYi4lPRdu5bM4JVJ3/UIHAiG6V6lWWUlkhB9ab4IOq0FrRsp0F4wTyV4Dj+Ds+efoXJ3qbLqlvaUozDO7OLeXA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0" + } + }, + "System.Composition.Hosting": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "w/wXjj7kvxuHPLdzZ0PAUt++qJl03t7lENmb2Oev0n3zbxyNULbWBlnd5J5WUMMv15kg5o+/TCZFb6lSwfaUUQ==", + "dependencies": { + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Composition.Runtime": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "qkRH/YBaMPTnzxrS5RDk1juvqed4A6HOD/CwRcDGyPpYps1J27waBddiiq1y93jk2ZZ9wuA/kynM+NO0kb3PKg==" + }, + "System.Composition.TypedParts": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "iUR1eHrL8Cwd82neQCJ00MpwNIBs4NZgXzrPqx8NJf/k4+mwBO0XCRmHYJT4OLSwDDqh5nBLJWkz5cROnrGhRA==", + "dependencies": { + "System.Composition.AttributedModel": "6.0.0", + "System.Composition.Hosting": "6.0.0", + "System.Composition.Runtime": "6.0.0" + } + }, + "System.Formats.Asn1": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "6.0.3", + "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "III/lNMSn0ZRBuM9m5Cgbiho5j81u0FAEagFX5ta2DKbljZ3T0IpD8j+BIiHQPeKqJppWS9bGEp6JnKnWKze0g==", + "dependencies": { + "System.Collections.Immutable": "6.0.0" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q==" + } + } + } +} \ No newline at end of file diff --git a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj index 355d11c..77b9003 100644 --- a/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj +++ b/tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj @@ -20,6 +20,12 @@ + + + + + + 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/ConceptAppraisalServiceTests.cs b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs new file mode 100644 index 0000000..3bd6f8d --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptAppraisalServiceTests.cs @@ -0,0 +1,600 @@ +using System.Net; +using System.Reflection; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptAppraisalServiceTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7"); + + [Fact] + public async Task Anonymous_device_voter_can_cast_and_is_counted() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + Assert.Equal(CastResult.Recorded, result); + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally); + } + + [Fact] + public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null)); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon")); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Theory] + [InlineData("abc\n")] + [InlineData("abc\r\n")] + [InlineData("abc ")] + [InlineData("abc\t")] + [InlineData("abc\0")] + public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged) + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up)); + + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged)); + Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context() + { + var dbName = $"concept-{Guid.NewGuid():N}"; + await using var first = new Fixture(dbName); + await first.AddSubject("ship-a"); + Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up)); + var other = IPAddress.Parse("198.51.100.9"); + + // Production scopes one DbContext per request: the recast gets a context that has never tracked the row. + await using (var second = new Fixture(dbName)) + { + second.Clock.Advance(TimeSpan.FromMinutes(5)); + Assert.Equal( + CastResult.Changed, + await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other)); + } + + await using var reader = new Fixture(dbName); + var row = Assert.Single(reader.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc); + Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]); + } + + [Fact] + public async Task Voter_class_is_stamped_at_cast() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var rows = f.Db.ConceptAppraisals.ToList(); + Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass); + Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId); + var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id); + var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id); + Assert.Equal(VoterClass.Account, accountRow.VoterClass); + Assert.Equal(VoterClass.Member, memberRow.VoterClass); + + // Membership ends; the same account votes again with the opposite stance. The row keeps its stamp. + member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated; + var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, recast); + Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass); + } + + [Fact] + public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up)); + Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down)); + Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down)); + + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public async Task Member_weight_does_not_weight_counts() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var member = f.Account(MembershipStatus.Active, weight: 5m); + Assert.Equal(5m, member.EffectiveVotingWeight); + + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + Assert.Equal(1, tally.Up); + Assert.Equal(1, tally.Members.Up); + } + + [Fact] + public async Task Member_tally_is_a_filter_over_the_same_rows() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down); + + var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"]; + var rows = f.Db.ConceptAppraisals.ToList(); + + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up); + Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down); + Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally); + } + + [Fact] + public async Task Tally_carries_no_voter_identity() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var account = f.Account(MembershipStatus.Authenticated); + var member = f.Account(MembershipStatus.Active); + await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down); + + Assert.Equal( + new[] { "Down", "Members", "Up" }, + typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + Assert.Equal( + new[] { "Down", "Up" }, + typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name).Order().ToArray()); + + var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default)); + var view = JsonSerializer.Serialize( + await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default)); + foreach (var text in new[] { json, view }) + { + Assert.DoesNotContain("device:", text); + Assert.DoesNotContain("account:", text); + Assert.DoesNotContain("device-secret-id", text); + foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id }) + { + Assert.DoesNotContain(id.ToString("D"), text); + Assert.DoesNotContain(id.ToString("N"), text); + } + + foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct()) + { + Assert.DoesNotContain(tag, text); + } + } + } + + [Fact] + public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-old", retired: true); + var subjectsBefore = f.Db.ConceptSubjects.Count(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up)); + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up)); + + Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count()); + Assert.Empty(f.Db.ConceptAppraisals); + Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys); + } + + [Fact] + public async Task Retract_removes_the_row_and_the_count() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up); + + Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default)); + + Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Network_tag_is_an_hmac_never_a_raw_address() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + + var tag = f.Db.ConceptAppraisals.Single().NetworkTag; + + Assert.Matches("^[0-9a-f]{64}$", tag); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag); + } + + [Fact] + public async Task Unique_violation_at_insert_becomes_a_change() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName); + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down); + + Assert.Equal(CastResult.Changed, result); + Assert.Equal(1, racer.Fired); + var row = Assert.Single(f.Db.ConceptAppraisals); + Assert.Equal(ConceptStance.Down, row.Stance); + } + + [Fact] + public void Unique_indexes_are_declared_in_model_and_migration() + { + var options = new DbContextOptionsBuilder().UseNpgsql("Host=unused").Options; + using var db = new BifrostDbContext(options); + + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"])); + Assert.Contains( + db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(), + i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"])); + + var script = db.Database.GenerateCreateScript(); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script); + Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script); + + } + + [Fact] + public async Task Purge_removes_only_rows_in_tag_and_window_and_audits() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var admin = f.Admin(); + var t0 = f.Clock.GetUtcNow(); + var other = IPAddress.Parse("198.51.100.9"); + + await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up); + await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other); + f.Clock.Advance(TimeSpan.FromHours(2)); + await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default); + + Assert.Equal(1, removed); + Assert.Equal( + ["device:device-late", "device:device-other-net"], + f.Db.ConceptAppraisals.Select(x => x.VoterKey).ToList().Order().ToArray()); + var audit = Assert.Single(f.Db.AuditEvents); + Assert.Equal("ConceptAppraisal", audit.EntityType); + Assert.Equal("Purge", audit.Action); + Assert.DoesNotContain(tag, audit.Detail); + Assert.DoesNotContain("tag", audit.Detail); + Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId); + Assert.Contains("from=" + t0.ToString("O"), audit.Detail); + Assert.Contains("count=1", audit.Detail); + Assert.DoesNotContain("device", audit.Detail); + + var refused = await Assert.ThrowsAsync( + () => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default)); + Assert.DoesNotContain(tag, refused.Message); + Assert.Equal("Only a platform administrator can purge appraisals.", refused.Message); + Assert.Equal(2, f.Db.ConceptAppraisals.Count()); + Assert.Single(f.Db.AuditEvents); + } + + [Fact] + public async Task Unique_violation_with_no_surviving_row_is_not_swallowed() + { + var dbName = $"race-{Guid.NewGuid():N}"; + var racer = new RacingInterceptor(dbName) { InsertRival = false }; + await using var f = new Fixture(dbName, racer); + await f.AddSubject("ship-a"); + racer.Subject = f.Db.ConceptSubjects.Single(); + + await Assert.ThrowsAsync( + () => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up)); + } + + [Fact] + public async Task A_changed_cast_restamps_tag_and_time() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up, IPAddress.Parse("198.51.100.9")); + var first = f.Db.ConceptAppraisals.Single().CastAtUtc; + f.Clock.Advance(TimeSpan.FromMinutes(5)); + + await f.Cast("ship-a", voter, ConceptStance.Down); + + var row = f.Db.ConceptAppraisals.Single(); + Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag); + Assert.Equal(first.AddMinutes(5), row.CastAtUtc); + } + + [Fact] + public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default); + + Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag); + var t0 = f.Clock.GetUtcNow(); + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default)); + Assert.Single(f.Db.ConceptAppraisals); + Assert.Empty(f.Db.AuditEvents); + } + + [Fact] + public async Task Purge_window_excludes_its_end_and_includes_its_start() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var t0 = f.Clock.GetUtcNow(); + await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up); + var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret); + + Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default)); + Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default)); + } + + [Fact] + public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + var voter = f.Anonymous("device-one"); + await f.Cast("ship-a", voter, ConceptStance.Up); + + Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default)); + Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default)); + Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default)); + Assert.Single(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance() + { + await using var f = new Fixture(); + await f.AddSubject("ship-a"); + await f.AddSubject("ship-b"); + await f.AddSubject("ship-old", retired: true); + var mine = f.Anonymous("device-one"); + await f.Cast("ship-a", mine, ConceptStance.Down); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up); + await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up); + await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up); + + var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default); + var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default); + var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default); + + Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally); + Assert.Equal(ConceptStance.Down, view.YourStance); + Assert.Null(stranger!.YourStance); + Assert.Null(anon!.YourStance); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default)); + Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default)); + } + + [Fact] + public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists() + { + await using var f = new Fixture(); + await f.AddSubject("Ship_A"); + f.Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = "Bad_Collection", + Key = "ship-a", + Title = "t", + RegisteredAtUtc = f.Clock.GetUtcNow() + }); + await f.Db.SaveChangesAsync(); + var voter = f.Anonymous("device-one"); + + Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up)); + Assert.Equal( + CastResult.UnknownSubject, + await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default)); + Assert.Empty(f.Db.ConceptAppraisals); + } + + [Fact] + public async Task Voter_keys_have_their_documented_shape() + { + await using var f = new Fixture(); + var account = f.Account(MembershipStatus.Authenticated); + + Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key); + Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key); + Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101))); + Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100))); + } + + private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor + { + public bool InsertRival { get; init; } = true; + + public ConceptSubject? Subject { get; set; } + + public int Fired { get; private set; } + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + var adding = eventData.Context!.ChangeTracker.Entries() + .Any(e => e.State == EntityState.Added); + if (Subject is null || !adding || Fired > 0) + { + return result; + } + + Fired++; + if (!InsertRival) + { + throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(dbName).Options; + await using (var other = new BifrostDbContext(options)) + { + other.ConceptAppraisals.Add(new ConceptAppraisal + { + SubjectId = Subject.Id, + VoterKey = "device:device-one", + Stance = ConceptStance.Up, + VoterClass = VoterClass.Anonymous, + CastAtUtc = DateTimeOffset.UtcNow + }); + await other.SaveChangesAsync(cancellationToken); + } + + throw new DbUpdateException( + "conflict", + new PostgresException("duplicate", "ERROR", "ERROR", "23505")); + } + } + + private sealed class Fixture : IAsyncDisposable + { + public const string Secret = "test-tag-secret"; + + public Fixture(string? dbName = null, IInterceptor? interceptor = null) + { + var builder = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}"); + if (interceptor is not null) + { + builder.AddInterceptors(interceptor); + } + + Db = new BifrostDbContext(builder.Options); + Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + Service = new ConceptAppraisalService( + Db, + new AuditTrailService(Db, Clock), + Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret })); + } + + public BifrostDbContext Db { get; } + + public ManualClock Clock { get; } + + public ConceptAppraisalService Service { get; } + + public async Task AddSubject(string key, bool retired = false) + { + Db.ConceptSubjects.Add(new ConceptSubject + { + Collection = Collection, + Key = key, + Title = key, + RegisteredAtUtc = Clock.GetUtcNow(), + RetiredAtUtc = retired ? Clock.GetUtcNow() : null + }); + await Db.SaveChangesAsync(); + } + + public Task Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) => + Service.CastAsync(Collection, key, voter, stance, client ?? Client, default); + + public VoterIdentity Anonymous(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + public CurrentBifrostActor Account(MembershipStatus status, decimal weight = 0m, bool admin = false) + { + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = Clock.GetUtcNow(), + LastSeenAtUtc = Clock.GetUtcNow() + }; + user.Membership = new Membership + { + UserAccountId = user.Id, + Status = status, + IsPlatformAdmin = admin, + CreatedAtUtc = Clock.GetUtcNow() + }; + if (weight > 0m) + { + user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true }); + } + + Db.UserAccounts.Add(user); + Db.SaveChanges(); + return new CurrentBifrostActor(true, user); + } + + public CurrentBifrostActor Admin() + { + return Account(MembershipStatus.Active, admin: true); + } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} 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/ConceptSubjectRegistryTests.cs b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs new file mode 100644 index 0000000..c942457 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptSubjectRegistryTests.cs @@ -0,0 +1,673 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Bifrost.Web.Configuration; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class ConceptSubjectRegistryTests +{ + private const string Collection = "aetheria-concept-mill"; + private static readonly string[] None = []; + + private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json"); + + private static IReadOnlyList Parse(string json) => + ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json)); + + private static IReadOnlyList Fixture() => + ConceptSubjectRegistry.ParseManifest(File.ReadAllBytes(FixturePath)); + + [Fact] + public async Task Rerun_of_the_same_manifest_is_a_no_op() + { + using var world = new World(); + + var first = await world.Apply(Fixture()); + Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject)))); + + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + var second = await world.Apply(Fixture()); + + Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change)); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)), + after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc))); + } + + [Fact] + public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan() + { + using var world = new World(); + + var plan = await world.Plan(Fixture()); + Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + await world.Apply(Fixture()); + + var events = await world.Fresh(db => db.AuditEvents.ToListAsync()); + var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet(); + Assert.Equal(3, events.Count); + Assert.All(events, e => + { + Assert.Equal(nameof(ConceptSubject), e.EntityType); + Assert.Equal("Add", e.Action); + Assert.Null(e.ActorUserAccountId); + Assert.Contains("actor=host-command", e.Detail); + Assert.Contains(e.EntityId, subjectIds); + }); + } + + [Fact] + public async Task Rename_changes_title_and_keeps_votes() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("bison", ConceptStance.Up); + await world.Cast("kestrel", ConceptStance.Down); + + var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList(); + var plan = await world.Apply(renamed); + + Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change); + Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged)); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison")); + Assert.Equal("Bison Prime", subject.Title); + Assert.Null(subject.RetiredAtUtc); + Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default)); + Assert.Equal("Bison Prime", view!.Title); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename"))); + } + + [Fact] + public async Task Absent_key_is_retired_only_when_named() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + var smaller = Fixture().Where(s => s.Key != "kestrel").ToList(); + + var unnamed = await world.Apply(smaller); + Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + + var named = await world.Apply(smaller, ["kestrel"]); + Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change); + var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.NotNull(retired.RetiredAtUtc); + Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync())); + Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys); + Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down)); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Theory] + [InlineData("bison")] + [InlineData("never-registered")] + public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named) + { + using var world = new World(); + await world.Apply(Fixture()); + var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList(); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(renamed, [named])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.DoesNotContain(named, refusal.Message); + Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Rerunning_the_same_retire_list_converges_and_writes_nothing() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); + + var first = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + Assert.Equal([SubjectChange.Unchanged, SubjectChange.Retire, SubjectChange.Retire], first.Entries.Select(e => e.Change)); + var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + var audits = await world.Fresh(db => db.AuditEvents.CountAsync()); + world.Clock.Advance(TimeSpan.FromHours(1)); + + var second = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal( + [("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)], + second.Entries.Select(e => (e.Key, e.Change))); + Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync())); + var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync()); + Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc))); + } + + [Fact] + public async Task Rerunning_a_retire_list_after_a_partial_apply_retires_the_rest() + { + using var world = new World(); + await world.Apply(Fixture()); + var smaller = Fixture().Where(s => s.Key == "bison").ToList(); + await world.Apply(smaller, ["kestrel"]); + + var plan = await world.Apply(smaller, ["kestrel", "ibis-mk2"]); + + Assert.Equal(SubjectChange.Retired, plan.Entries.Single(e => e.Key == "kestrel").Change); + Assert.Equal(SubjectChange.Retire, plan.Entries.Single(e => e.Key == "ibis-mk2").Change); + var rows = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().ToListAsync()); + Assert.Equal(["ibis-mk2", "kestrel"], rows.Where(r => r.RetiredAtUtc is not null).Select(r => r.Key).Order()); + Assert.Equal(2, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire"))); + } + + [Fact] + public async Task Naming_a_retired_key_that_the_manifest_lists_again_is_still_refused() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var refusal = await Assert.ThrowsAsync(() => world.Apply(Fixture(), ["kestrel"])); + + Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code)); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + } + + [Fact] + public async Task Re_registering_a_retired_key_revives_it() + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Cast("kestrel", ConceptStance.Up); + await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]); + + var plan = await world.Apply(Fixture()); + + Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change); + var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel")); + Assert.Null(subject.RetiredAtUtc); + var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default)); + Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]); + Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive"))); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")] + [InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")] + [InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")] + [InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")] + [InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")] + [InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")] + [InlineData("""{"fleet":[]}""", "ships", "Missing")] + [InlineData("[]", "ships", "Missing")] + [InlineData("""{"ships":5}""", "ships", "Missing")] + [InlineData("not json", "manifest", "NotJson")] + public async Task Malformed_manifest_is_refused_whole(string json, string field, string code) + { + using var world = new World(); + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + Assert.Equal($"{field}: {code}", refusal.Message); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused() + { + var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength); + + Assert.Equal(fits, Parse($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title); + var refusal = Assert.Throws(() => + Parse($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}""")); + Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code)); + } + + private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + [Theory] + [InlineData("\u202Eevil")] + [InlineData("a\u202Ab")] + [InlineData("a\u2066b")] + [InlineData("a\u2069b")] + [InlineData("a\u061Cb")] + [InlineData("a\u200Eb")] + [InlineData("a\u200Fb")] + [InlineData("a\u2028b")] + [InlineData("a\u2029b")] + [InlineData("a\u200Bb")] + [InlineData("a\u200Cb")] + [InlineData("a\u200Db")] + [InlineData("a\u2060b")] + [InlineData("a\uFEFFb")] + [InlineData("a\u0085b")] + [InlineData("a\tb")] + [InlineData(" Ibis")] + [InlineData("Ibis ")] + [InlineData("\u00A0Ibis")] + [InlineData("Ibis\u00A0")] + [InlineData("\u3000Ibis")] + [InlineData("!!!")] + public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("ships[0].name", "InvalidTitle"), (refusal.Field, refusal.Code)); + } + + [Theory] + [InlineData("Ibis Mk II")] + [InlineData("Ünïcode Ship")] + [InlineData("Ship ★ 7")] + [InlineData("7")] + [InlineData("Kestrel-2: the \"Bold\"")] + public void An_ordinary_title_is_accepted(string title) + { + var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}"; + + Assert.Equal(title, Parse(json).Single().Title); + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","name":"x\ud800y"}]}""", "ships[0].name", "BadText")] + [InlineData("""{"ships":[{"id":"a\udc00","name":"x"}]}""", "ships[0].id", "BadText")] + [InlineData("""{"ships":[{"id":"a","name":"x","na\ud800me":"y"}]}""", "manifest", "BadText")] + public void A_lone_surrogate_is_refused_not_thrown(string json, string field, string code) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal((field, code), (refusal.Field, refusal.Code)); + } + + [Fact] + public void Malformed_utf8_anywhere_in_the_file_is_refused() + { + byte[] inName = [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"X"u8, 0xC3, 0x28, .. "\"}]}"u8]; + byte[] inUnreadValue = [.. "{\"ships\":[],\"x\":\"X"u8, 0xFF, .. "\"}"u8]; + + foreach (var bytes in new[] { inName, inUnreadValue }) + { + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(bytes)); + + Assert.Equal(("manifest", "NotUtf8"), (refusal.Field, refusal.Code)); + } + } + + [Theory] + [InlineData("""{"ships":[{"id":"a","id":"b","name":"A"}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","name":"B"}]}""")] + [InlineData("""{"ships":[],"ships":[]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A"}],"votes":{"x":1,"x":2}}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","meta":{"k":[{"z":1,"z":1}]}}]}""")] + [InlineData("""{"ships":[{"id":"a","name":"A","id":"b"}]}""")] + public void A_duplicate_json_property_is_refused_wherever_it_sits(string json) + { + var refusal = Assert.Throws(() => Parse(json)); + + Assert.Equal(("manifest", "DuplicateProperty"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void The_same_property_name_in_sibling_and_nested_objects_is_not_a_duplicate() + { + var ships = Parse("""{"id":"top","ships":[{"id":"a","name":"A","meta":{"id":"x","name":"y"}},{"id":"b","name":"B"}]}"""); + + Assert.Equal(["a", "b"], ships.Select(s => s.Key)); + } + + [Fact] + public void A_manifest_of_exactly_the_byte_bound_is_accepted_and_one_more_byte_is_refused() + { + const string head = """{"ships":[{"id":"a","name":"A"}]}"""; + var fits = Encoding.UTF8.GetBytes(head + new string(' ', ConceptSubjectRegistry.MaxManifestBytes - head.Length)); + + Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length); + Assert.Single(ConceptSubjectRegistry.ParseManifest(fits)); + var refusal = Assert.Throws(() => ConceptSubjectRegistry.ParseManifest(fits.Append((byte)' ').ToArray())); + Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code)); + } + + [Fact] + public void A_manifest_of_exactly_the_ship_bound_is_accepted_and_one_more_ship_is_refused() + { + static string Fleet(int count) => + """{"ships":[""" + string.Join(",", Enumerable.Range(0, count).Select(i => $$"""{"id":"s{{i}}","name":"S{{i}}"}""")) + "]}"; + + Assert.Equal(ConceptSubjectRegistry.MaxShips, Parse(Fleet(ConceptSubjectRegistry.MaxShips)).Count); + var refusal = Assert.Throws(() => Parse(Fleet(ConceptSubjectRegistry.MaxShips + 1))); + Assert.Equal(("ships", "TooMany"), (refusal.Field, refusal.Code)); + } + + [Fact] + public async Task Absent_keys_are_planned_in_key_order() + { + using var world = new World(); + await world.Apply(Fixture()); + + var plan = await world.Plan([new ManifestShip("zzz-new", "New")]); + + Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key)); + } + + [Fact] + public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row() + { + using var world = new World(); + var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}"""; + + Assert.Throws(() => Parse(json)); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Fact] + public async Task Refusal_never_echoes_the_offending_value() + { + const string canary = "CANARY-secret/value"; + var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}"""; + + var refusal = Assert.Throws(() => Parse(json)); + Assert.DoesNotContain("CANARY", refusal.Message); + using var world = new World(); + var collection = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default)); + Assert.DoesNotContain("CANARY", collection.Message); + Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code)); + var retire = await Assert.ThrowsAsync(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default)); + Assert.DoesNotContain("CANARY", retire.Message); + Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code)); + } + + [Theory] + [InlineData("ship-a\n")] + [InlineData("ship-a\r\n")] + [InlineData("ship-a ")] + public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged) + { + using var world = new World(); + await world.Apply(Fixture()); + await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default); + + Assert.False(ConceptKey.IsValid(forged)); + Assert.False(ConceptKey.IsValid("key\n")); + Assert.True(ConceptKey.IsValid("ship-a")); + Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up)); + Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default))); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default)); + await Assert.ThrowsAsync(() => + world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default)); + Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync())); + } + + [Fact] + public async Task Host_command_plans_and_applies_with_exit_codes() + { + using var world = new World(); + var services = world.Services(); + + var plan = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan)); + Assert.Contains("add bison", plan.ToString()); + Assert.Contains("add ibis-mk2", plan.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync())); + + var apply = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply)); + Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync())); + + var again = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again)); + Assert.Contains("unchanged bison", again.ToString()); + Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync())); + } + + [Fact] + public async Task Host_command_retire_flag_retires_the_named_absent_key() + { + using var world = new World(); + var services = world.Services(); + await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter()); + var smaller = Path.Combine(world.Dir, "two.json"); + await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}"""); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output)); + + Assert.Contains("retire kestrel", output.ToString()); + Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc); + } + + [Fact] + public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input() + { + using var world = new World(); + var services = world.Services(); + var bad = Path.Combine(world.Dir, "bad.json"); + await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}"""); + + var refused = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused)); + Assert.Contains("ships[0].id", refused.ToString()); + Assert.DoesNotContain("CANARY", refused.ToString()); + + var missing = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing)); + Assert.DoesNotContain("CANARY", missing.ToString()); + Assert.Equal("refused manifest Unreadable", missing.ToString().Trim()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + } + + [Theory] + [InlineData("frobnicate", "c", "m")] + [InlineData("plan", "c")] + [InlineData("apply", "c", "m", "--bogus", "x")] + [InlineData("apply", "c", "m", "--retire")] + public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output)); + Assert.StartsWith("usage:", output.ToString()); + Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync())); + Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter())); + } + + [Theory] + [InlineData("")] + [InlineData("CANARY\0path.json")] + public async Task Host_command_refuses_an_empty_or_nul_path(string path) + { + using var world = new World(); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest Unreadable", output.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_directory_and_an_oversized_file() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "big.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + + var dir = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, world.Dir], world.Services(), dir)); + Assert.Equal("refused manifest Unreadable", dir.ToString().Trim()); + var large = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, big], world.Services(), large)); + Assert.Equal("refused manifest TooLarge", large.ToString().Trim()); + } + + [Fact] + public async Task Host_command_refuses_a_manifest_that_is_not_utf8_text() + { + using var world = new World(); + var path = Path.Combine(world.Dir, "latin1.json"); + await File.WriteAllBytesAsync(path, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARY-"u8, 0xE9, .. "\"}]}"u8]); + + var output = new StringWriter(); + Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output)); + + Assert.Equal("refused manifest NotUtf8", output.ToString().Trim()); + } + + [Fact] + public async Task Built_command_exits_with_its_documented_codes_and_echoes_no_input() + { + using var world = new World(); + var big = Path.Combine(world.Dir, "CANARYBIG.json"); + await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]); + var bidi = Path.Combine(world.Dir, "CANARYBIDI.json"); + await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE\u202E"}]}"""); + var dup = Path.Combine(world.Dir, "CANARYDUP.json"); + await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}"""); + var surrogate = Path.Combine(world.Dir, "CANARYSURR.json"); + await File.WriteAllTextAsync(surrogate, """{"ships":[{"id":"a","name":"CANARYNAME\ud800"}]}"""); + var latin1 = Path.Combine(world.Dir, "CANARYLATIN.json"); + await File.WriteAllBytesAsync(latin1, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARYBYTES"u8, 0xE9, .. "\"}]}"u8]); + + var cases = new (string[] Args, int Code)[] + { + (["plan", Collection, ""], ConceptCommand.Refused), + (["plan", Collection, Path.Combine(world.Dir, "CANARYMISSING.json")], ConceptCommand.Refused), + (["plan", Collection, world.Dir], ConceptCommand.Refused), + (["plan", Collection, big], ConceptCommand.Refused), + (["plan", Collection, bidi], ConceptCommand.Refused), + (["apply", Collection, dup], ConceptCommand.Refused), + (["apply", Collection, surrogate], ConceptCommand.Refused), + (["plan", Collection, latin1], ConceptCommand.Refused), + (["plan", "CANARYCOLLECTION", bidi], ConceptCommand.Refused), + (["frob", "CANARYARG", "x"], ConceptCommand.Usage), + (["plan"], ConceptCommand.Usage), + }; + foreach (var (args, code) in cases) + { + var result = await RunBuiltCommandAsync(args); + + Assert.True(code == result.ExitCode, $"case {args[0]}/{args.Length}: exit {result.ExitCode}, stdout {result.StdOut}, stderr {result.StdErr}"); + Assert.DoesNotContain("CANARY", result.StdOut + result.StdErr); + Assert.DoesNotContain("Unhandled exception", result.StdOut + result.StdErr); + } + } + + private static async Task<(int ExitCode, string StdOut, string StdErr)> RunBuiltCommandAsync(string[] args) + { + var dll = Path.Combine(AppContext.BaseDirectory, "Bifrost.Web.dll"); + Assert.True(File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json")), "Bifrost.Web.runtimeconfig.json is not beside the test assembly"); + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + foreach (var part in new[] { dll, "concepts" }.Concat(args)) + { + start.ArgumentList.Add(part); + } + + // Every case refuses before the database is touched; the password is a canary for leaks. + start.Environment["ConnectionStrings__Bifrost"] = "Host=127.0.0.1;Port=1;Database=x;Username=u;Password=CANARYPW"; + using var process = Process.Start(start)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + await process.WaitForExitAsync(timeout.Token); + return (process.ExitCode, await stdout, await stderr); + } + + private sealed class World : IDisposable + { + private readonly string _dbName = $"registry-{Guid.NewGuid():N}"; + + public World() + { + Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}"); + Directory.CreateDirectory(Dir); + } + + public string Dir { get; } + + public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero)); + + public BifrostDbContext NewDb() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(_dbName).Options); + + public ConceptSubjectRegistry Registry(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock); + + public ConceptAppraisalService Appraisals(BifrostDbContext db) => + new(db, new AuditTrailService(db, Clock), Clock, + Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" })); + + public async Task Apply(IReadOnlyList ships, string[]? retire = null) + { + await using var db = NewDb(); + return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default); + } + + public async Task Plan(IReadOnlyList ships) + { + await using var db = NewDb(); + return await Registry(db).PlanAsync(Collection, ships, None, default); + } + + public async Task Cast(string key, ConceptStance stance) + { + await using var db = NewDb(); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + stance)!; + return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default); + } + + public async Task Fresh(Func> read) + { + await using var db = NewDb(); + return await read(db); + } + + public IServiceProvider Services() + { + var services = new ServiceCollection(); + services.AddDbContext(o => o.UseInMemoryDatabase(_dbName)); + services.AddSingleton(Clock); + services.AddScoped(); + services.AddScoped(); + return services.BuildServiceProvider(); + } + + public void Dispose() => Directory.Delete(Dir, true); + } + + private sealed class ManualClock(DateTimeOffset start) : TimeProvider + { + private DateTimeOffset _now = start; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs new file mode 100644 index 0000000..bd6a72f --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptTallyExportTests.cs @@ -0,0 +1,383 @@ +using System.Net; +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 ConceptTallyExportTests : IClassFixture +{ + private const string AllowedOrigin = "https://aetheria.gamecult.org"; + private const string Client = "203.0.113.7"; + + private readonly ExportFactory _factory; + + public ConceptTallyExportTests(ExportFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_request_gets_the_public_counts() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "beta"); + await AddSubject(collection, "untouched"); + await Cast(collection, "alpha", DeviceVoter("dev-one"), ConceptStance.Up); + await Cast(collection, "alpha", DeviceVoter("dev-two"), ConceptStance.Up); + await Cast(collection, "alpha", DeviceVoter("dev-three"), ConceptStance.Down); + await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Active), ConceptStance.Up); + await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Authenticated), ConceptStance.Down); + await Cast(collection, "beta", DeviceVoter("dev-one"), ConceptStance.Down); + + using var response = await Get(collection); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType); + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["alpha", "beta", "untouched"], body.EnumerateObject().Select(p => p.Name).Order().ToArray()); + AssertCounts(body.GetProperty("alpha"), up: 3, down: 2, memberUp: 1, memberDown: 0); + AssertCounts(body.GetProperty("beta"), up: 0, down: 1, memberUp: 0, memberDown: 0); + AssertCounts(body.GetProperty("untouched"), up: 0, down: 0, memberUp: 0, memberDown: 0); + } + + [Fact] + public async Task Payload_carries_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var account = await AccountVoter(MembershipStatus.Authenticated); + var member = await AccountVoter(MembershipStatus.Active); + await Cast(collection, "alpha", DeviceVoter("canary-device-id-9c2e"), ConceptStance.Up); + await Cast(collection, "alpha", account, ConceptStance.Down); + await Cast(collection, "alpha", member, ConceptStance.Up); + + using var response = await Get(collection); + + var text = await response.Content.ReadAsStringAsync(); + var alpha = JsonDocument.Parse(text).RootElement.GetProperty("alpha"); + Assert.Equal(["down", "members", "up"], alpha.EnumerateObject().Select(p => p.Name).Order().ToArray()); + Assert.Equal(["down", "up"], alpha.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray()); + + string[] tags; + using (var scope = _factory.Services.CreateScope()) + { + tags = await scope.ServiceProvider.GetRequiredService() + .ConceptAppraisals.Select(x => x.NetworkTag).Distinct().ToArrayAsync(); + } + + Assert.All(tags, tag => Assert.NotEmpty(tag)); + var forbidden = new List { "canary-device-id-9c2e", "device:", "account:", "voterkey", "networktag", "useraccount" }; + forbidden.AddRange(tags); + foreach (var voter in new[] { account, member }) + { + forbidden.Add(voter.UserAccountId!.Value.ToString("N")); + forbidden.Add(voter.UserAccountId!.Value.ToString("D")); + } + + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cors_allows_only_the_configured_origin_without_credentials() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var allowed = await Get(collection, AllowedOrigin); + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials")); + + using var otherHost = await Get(collection, "https://evil.example"); + Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Credentials")); + + using var preflightGet = await Preflight(collection, AllowedOrigin, "GET"); + Assert.Equal([AllowedOrigin], preflightGet.Headers.GetValues("Access-Control-Allow-Origin").ToArray()); + Assert.False(preflightGet.Headers.Contains("Access-Control-Allow-Credentials")); + + using var preflightPost = await Preflight(collection, AllowedOrigin, "POST"); + Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Methods")); + } + + [Theory] + [InlineData("https://aetheria.gamecult.org.evil.example")] + [InlineData("https://aetheria.gamecult.orgx")] + [InlineData("https://evilaetheria.gamecult.org")] + [InlineData("https://www.aetheria.gamecult.org")] + [InlineData("HTTPS://AETHERIA.GAMECULT.ORG")] + [InlineData("https://Aetheria.gamecult.org")] + [InlineData("https://aetheria.gamecult.org/")] + [InlineData("https://aetheria.gamecult.org:8443")] + [InlineData("http://aetheria.gamecult.org")] + [InlineData("null")] + [InlineData("*")] + [InlineData("")] + public async Task Cors_refuses_origins_that_only_resemble_the_configured_one(string origin) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var response = await Get(collection, origin); + using var preflight = await Preflight(collection, origin, "GET"); + + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(preflight.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Every_export_response_varies_by_origin() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var allowed = await Get(collection, AllowedOrigin); + using var lookalike = await Get(collection, "https://aetheria.gamecult.org.evil.example"); + using var noOrigin = await Get(collection); + using var unknownAllowed = await Get(NewCollection(), AllowedOrigin); + using var unknownNoOrigin = await Get(NewCollection()); + using var preflightAllowed = await Preflight(collection, AllowedOrigin, "GET"); + using var preflightLookalike = await Preflight(collection, "https://evil.example", "GET"); + using var preflightPost = await Preflight(collection, AllowedOrigin, "POST"); + + Assert.Equal(HttpStatusCode.OK, allowed.StatusCode); + Assert.Equal(HttpStatusCode.OK, lookalike.StatusCode); + Assert.Equal(HttpStatusCode.OK, noOrigin.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, unknownAllowed.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, unknownNoOrigin.StatusCode); + foreach (var response in new[] { allowed, lookalike, noOrigin, unknownAllowed, unknownNoOrigin, preflightAllowed, preflightLookalike, preflightPost }) + { + Assert.Contains("Origin", VaryTokens(response)); + } + } + + [Fact] + public async Task Origin_is_added_to_an_existing_vary() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.Add("X-Test-Vary", "Accept-Encoding"); + request.Headers.TryAddWithoutValidation("Origin", AllowedOrigin); + + using var response = await client.SendAsync(request); + + Assert.Equal(["Accept-Encoding", "Origin"], VaryTokens(response).Order().ToArray()); + } + + private static string[] VaryTokens(HttpResponseMessage response) => + response.Headers.Vary.ToArray(); + + [Fact] + public async Task Other_routes_carry_no_cors_headers() + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, "/healthz"); + request.Headers.Add("Origin", AllowedOrigin); + + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Access-Control-Allow-Origin")); + } + + [Fact] + public async Task Retired_subject_leaves_the_export() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Cast(collection, "gone", DeviceVoter("dev-one"), ConceptStance.Up); + await Retire(collection, "gone"); + + using var response = await Get(collection); + + var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement; + Assert.Equal(["alpha"], body.EnumerateObject().Select(p => p.Name).ToArray()); + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + Assert.Single(db.ConceptAppraisals.Where(x => x.Subject!.Key == "gone" && x.Subject.Collection == collection)); + } + + [Fact] + public async Task Cache_control_is_public_sixty_seconds() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var response = await Get(collection); + + var cache = response.Headers.CacheControl; + Assert.NotNull(cache); + Assert.True(cache!.Public); + Assert.Equal(TimeSpan.FromSeconds(60), cache.MaxAge); + } + + [Fact] + public async Task Unknown_collection_is_not_found() + { + using var response = await Get(NewCollection()); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task The_export_only_reads() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + int rowsBefore, auditBefore; + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + rowsBefore = db.ConceptAppraisals.Count(); + auditBefore = db.AuditEvents.Count(); + } + + using var response = await Get(collection); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.False(response.Headers.Contains("Set-Cookie")); + using var after = _factory.Services.CreateScope(); + var dbAfter = after.ServiceProvider.GetRequiredService(); + Assert.Equal(rowsBefore, dbAfter.ConceptAppraisals.Count()); + Assert.Equal(auditBefore, dbAfter.AuditEvents.Count()); + } + + private async Task Get(string collection, string? origin = null) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + return await client.SendAsync(request); + } + + private async Task Preflight(string collection, string origin, string method) + { + using var client = _factory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Options, $"/public/concepts/{collection}/tallies"); + request.Headers.Add("X-Test-Anonymous", "true"); + request.Headers.TryAddWithoutValidation("Origin", origin); + request.Headers.Add("Access-Control-Request-Method", method); + return await client.SendAsync(request); + } + + private static void AssertCounts(JsonElement tally, int up, int down, int memberUp, int memberDown) + { + Assert.Equal(up, tally.GetProperty("up").GetInt32()); + Assert.Equal(down, tally.GetProperty("down").GetInt32()); + Assert.Equal(memberUp, tally.GetProperty("members").GetProperty("up").GetInt32()); + Assert.Equal(memberDown, tally.GetProperty("members").GetProperty("down").GetInt32()); + } + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private static VoterIdentity DeviceVoter(string deviceId) => + VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!; + + private async Task AccountVoter(MembershipStatus status) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTimeOffset.UtcNow; + var user = new UserAccount + { + DisplayName = "voter", + GitHubLogin = "voter", + NormalizedGitHubLogin = "voter", + CreatedAtUtc = now, + LastSeenAtUtc = now, + Membership = new Membership { Status = status, CreatedAtUtc = now } + }; + user.Membership.UserAccountId = user.Id; + db.UserAccounts.Add(user); + await db.SaveChangesAsync(); + return VoterIdentity.For(new CurrentBifrostActor(true, user), null)!; + } + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private async Task Cast(string collection, string key, VoterIdentity voter, ConceptStance stance) + { + using var scope = _factory.Services.CreateScope(); + var result = await scope.ServiceProvider.GetRequiredService() + .CastAsync(collection, key, voter, stance, IPAddress.Parse(Client), default); + Assert.Equal(CastResult.Recorded, result); + } + + // Stands in for any earlier middleware that already set a Vary: a request header names the value to preset. + private sealed class PriorVaryFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + if (context.Request.Headers.TryGetValue("X-Test-Vary", out var prior)) + { + context.Response.Headers.Vary = prior; + } + + return nextMiddleware(context); + }); + next(app); + }; + } + + public sealed class ExportFactory : 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"] = "export-test-tag-secret-0123456789-abcdef" + })); + } + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs new file mode 100644 index 0000000..d0675d7 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVotePageTests.cs @@ -0,0 +1,674 @@ +using Bifrost.Web.Features.Shared; +using System.Net; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +public sealed partial class ConceptVotePageTests : IClassFixture +{ + private const string CookieName = BifrostCookies.Voter; + private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1"; + + private readonly TestWebApplicationFactory _factory; + + public ConceptVotePageTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Anonymous_visitor_can_cast_change_and_retract() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + var setCookie = SetCookieHeader(cast, CookieName); + Assert.NotNull(setCookie); + var attributes = setCookie!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.Contains("httponly", attributes); + Assert.Contains("secure", attributes); + Assert.Contains("samesite=lax", attributes); + Assert.Contains("path=/", attributes); + Assert.Contains("max-age=31536000", attributes); + + using (var page = await visitor.Get(collection, "alpha")) + { + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("0 up, 0 down", members); + } + + var firstRow = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Up, firstRow.Stance); + Assert.Equal(VoterClass.Anonymous, firstRow.VoterClass); + + // The cookie from the first cast is the voter: casting again changes the row and mints nothing. + using var change = await visitor.Post(collection, "alpha", "Down"); + Assert.Equal(HttpStatusCode.Redirect, change.StatusCode); + Assert.Null(SetCookieHeader(change, CookieName)); + var changed = Assert.Single(await Rows(collection, "alpha")); + Assert.Equal(ConceptStance.Down, changed.Stance); + Assert.Equal(firstRow.VoterKey, changed.VoterKey); + + using var retract = await visitor.Post(collection, "alpha", "Retract"); + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Fact] + public async Task Get_never_sets_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var first = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Null(SetCookieHeader(first, CookieName)); + } + + using (var retract = await visitor.Post(collection, "alpha", "Retract")) + { + Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode); + Assert.Null(SetCookieHeader(retract, CookieName)); + } + + Assert.False(visitor.Jar.ContainsKey(CookieName)); + Assert.Empty(await Rows(collection, "alpha")); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + using var later = await visitor.Get(collection, "alpha"); + Assert.Null(SetCookieHeader(later, CookieName)); + } + + [Fact] + public async Task Signed_in_vote_is_keyed_to_the_account() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: false); + + using var cast = await visitor.Post(collection, "alpha", "Up"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.Null(SetCookieHeader(cast, CookieName)); + var row = Assert.Single(await Rows(collection, "alpha")); + Assert.StartsWith("account:", row.VoterKey, StringComparison.Ordinal); + Assert.Equal(VoterClass.Member, row.VoterClass); + Assert.NotNull(row.UserAccountId); + + using var page = await visitor.Get(collection, "alpha"); + var (everyone, members) = Counts(await page.Content.ReadAsStringAsync()); + Assert.Equal("1 up, 0 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Null(SetCookieHeader(page, CookieName)); + } + + private const string SiteOrigin = "https://localhost"; + + [Fact] + public async Task Cast_from_the_sites_own_origin_is_accepted_and_writes_one_row() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: SiteOrigin); + Assert.Equal(HttpStatusCode.Redirect, byOrigin.StatusCode); + Assert.Single(await Rows(collection, "alpha")); + + using var byFetchSite = await visitor.Post(collection, "alpha", "Down", origin: null, fetchSite: "same-origin"); + Assert.Equal(HttpStatusCode.Redirect, byFetchSite.StatusCode); + Assert.Equal(ConceptStance.Down, Assert.Single(await Rows(collection, "alpha")).Stance); + } + + [Theory] + [InlineData("https://localhost.evil.example")] + [InlineData("https://evil-localhost")] + [InlineData("https://sub.localhost")] + [InlineData("https://LOCALHOST")] + [InlineData("HTTPS://localhost")] + [InlineData("https://localhost:8443")] + [InlineData("http://localhost")] + [InlineData("null")] + [InlineData("https://localhost/")] + [InlineData("https://evil.example")] + public async Task Cast_from_any_other_origin_is_refused_even_when_fetch_site_says_same_origin(string origin) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + using var response = await visitor.Post(collection, "alpha", handler, origin: origin, fetchSite: "same-origin"); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(SetCookieHeader(response, CookieName)); + Assert.False(response.Headers.Contains("Set-Cookie")); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + Assert.Empty(await Rows(collection, "alpha")); + } + + [Fact] + public async Task Cast_with_the_origin_header_repeated_is_refused_even_when_every_value_is_the_sites() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up") + { + Content = new FormUrlEncodedContent([]) + }; + request.Headers.TryAddWithoutValidation("Origin", [SiteOrigin, SiteOrigin]); + + using var response = await visitor.Send(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Theory] + [InlineData(null)] + [InlineData("same-origin")] + public async Task Cast_with_an_empty_origin_header_is_refused_whatever_fetch_site_says(string? fetchSite) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + // HttpClient drops a header whose value is empty, so the request is built on the server itself, where an + // Origin header with an empty value is present in the dictionary as a browser's would be. + var context = await _factory.Server.SendAsync(http => + { + http.Request.Method = HttpMethods.Post; + http.Request.Scheme = "https"; + http.Request.Host = new HostString("localhost"); + http.Request.Path = $"/mill/{collection}/alpha"; + http.Request.QueryString = new QueryString("?handler=Up"); + http.Request.ContentType = "application/x-www-form-urlencoded"; + http.Request.Headers["X-Test-Anonymous"] = "true"; + http.Request.Headers["Origin"] = string.Empty; + if (fetchSite is not null) + { + http.Request.Headers["Sec-Fetch-Site"] = fetchSite; + } + }); + + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + Assert.False(context.Response.Headers.ContainsKey("Set-Cookie")); + Assert.Empty(await Rows(collection, "alpha")); + } + + [Theory] + [InlineData("same-origin", true)] + [InlineData("same-site", false)] + [InlineData("cross-site", false)] + [InlineData("none", false)] + [InlineData("Same-Origin", false)] + [InlineData("same-origin, cross-site", false)] + [InlineData(null, false)] + public async Task Cast_without_an_origin_header_needs_fetch_site_same_origin(string? fetchSite, bool accepted) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var response = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: fetchSite); + + Assert.Equal(accepted ? HttpStatusCode.Redirect : HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(accepted ? 1 : 0, (await Rows(collection, "alpha")).Count); + } + + [Fact] + public async Task Refusal_never_echoes_the_origin_or_fetch_site_values() + { + const string canary = "https://CANARY-origin-5e1c.example"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + + using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: canary); + using var byFetchSite = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: "CANARY-site-5e1c"); + + foreach (var response in new[] { byOrigin, byFetchSite }) + { + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var seen = await response.Content.ReadAsStringAsync() + + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("CANARY", seen, StringComparison.OrdinalIgnoreCase); + } + + Assert.DoesNotContain(logs.Lines, line => line.Contains("CANARY", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task A_page_view_sets_no_cookie_at_all_and_a_cast_sets_only_the_device_cookie() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using (var view = await visitor.Get(collection, "alpha")) + { + Assert.Equal(HttpStatusCode.OK, view.StatusCode); + Assert.False(view.Headers.Contains("Set-Cookie")); + var html = await view.Content.ReadAsStringAsync(); + Assert.DoesNotContain("__RequestVerificationToken", html, StringComparison.Ordinal); + } + + using var cast = await visitor.Post(collection, "alpha", "Up"); + var cookies = cast.Headers.GetValues("Set-Cookie").ToArray(); + Assert.Single(cookies); + Assert.StartsWith(CookieName + "=", cookies[0], StringComparison.Ordinal); + + using var later = await visitor.Get(collection, "alpha"); + Assert.False(later.Headers.Contains("Set-Cookie")); + } + + [Fact] + public async Task A_request_that_selects_no_handler_is_a_client_error_and_never_a_500() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true); + + using var noHandler = await visitor.Post(collection, "alpha", handler: null); + using var bogus = await visitor.Post(collection, "alpha", "CANARYQ9"); + using var put = await visitor.Post(collection, "alpha", "Up", method: HttpMethod.Put); + using var delete = await visitor.Post(collection, "alpha", null, method: HttpMethod.Delete); + using var getBogus = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=CANARYQ9")); + using var getUp = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=Up")); + + Assert.Equal(HttpStatusCode.BadRequest, noHandler.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, bogus.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, put.StatusCode); + Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, getBogus.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, getUp.StatusCode); + foreach (var response in new[] { noHandler, bogus, put, delete, getBogus, getUp }) + { + var status = (int)response.StatusCode; + Assert.InRange(status, 400, 499); + Assert.DoesNotContain("CANARYQ9", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.False(response.Headers.Contains("Set-Cookie")); + } + + Assert.Empty(await Rows(collection, "alpha")); + } + + public enum Forgery + { + OneCharacterChanged, + BareDeviceId, + SameKeyRingOtherPurpose, + OtherKeyRingSamePurpose + } + + [Theory] + [InlineData(Forgery.OneCharacterChanged)] + [InlineData(Forgery.BareDeviceId)] + [InlineData(Forgery.SameKeyRingOtherPurpose)] + [InlineData(Forgery.OtherKeyRingSamePurpose)] + public async Task Tampered_or_foreign_cookie_is_ignored_and_a_new_one_minted(Forgery kind) + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var victim = NewVisitor(anonymous: true); + using (await victim.Post(collection, "alpha", "Up")) + { + } + + var genuine = victim.Jar[CookieName]; + var victimRow = Assert.Single(await Rows(collection, "alpha")); + var deviceId = victimRow.VoterKey["device:".Length..]; + var forged = Forge(kind, genuine, deviceId); + Assert.NotEqual(genuine, forged); + if (kind is not Forgery.BareDeviceId) + { + Assert.Equal(genuine.Length, forged.Length); + Assert.Matches("^[A-Za-z0-9_-]+$", forged); + } + + var attacker = NewVisitor(anonymous: true); + attacker.Jar[CookieName] = forged; + using var cast = await attacker.Post(collection, "alpha", "Down"); + + Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode); + Assert.NotNull(SetCookieHeader(cast, CookieName)); + Assert.NotEqual(forged, attacker.Jar[CookieName]); + Assert.NotEqual(genuine, attacker.Jar[CookieName]); + var rows = await Rows(collection, "alpha"); + Assert.Equal(2, rows.Count); + var victimNow = Assert.Single(rows, x => x.VoterKey == victimRow.VoterKey); + Assert.Equal(ConceptStance.Up, victimNow.Stance); + var attackerRow = Assert.Single(rows, x => x.VoterKey != victimRow.VoterKey); + Assert.Equal(ConceptStance.Down, attackerRow.Stance); + Assert.Equal(VoterClass.Anonymous, attackerRow.VoterClass); + } + + [Fact] + public async Task A_forged_cookie_value_is_echoed_by_no_response_and_no_log_line() + { + const string canary = "CANARY-forged-voter-cookie-7f3a"; + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var visitor = NewVisitor(anonymous: true, factory); + visitor.Jar[CookieName] = canary; + + using var page = await visitor.Get(collection, "alpha"); + using var cast = await visitor.Post(collection, "alpha", "Up"); + + foreach (var response in new[] { page, cast }) + { + Assert.DoesNotContain(canary, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal); + Assert.DoesNotContain( + canary, + string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))), + StringComparison.Ordinal); + } + + Assert.NotEmpty(logs.Lines); + Assert.DoesNotContain(logs.Lines, line => line.Contains(canary, StringComparison.Ordinal)); + } + + [Fact] + public async Task Unknown_or_retired_key_is_404() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + await AddSubject(collection, "gone"); + await Retire(collection, "gone"); + var visitor = NewVisitor(anonymous: true); + + foreach (var (c, k) in new[] { (collection, "gone"), (collection, "never-registered"), (NewCollection(), "alpha") }) + { + using var get = await visitor.Get(c, k); + Assert.Equal(HttpStatusCode.NotFound, get.StatusCode); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + using var post = await visitor.Post(c, k, handler); + Assert.Equal(HttpStatusCode.NotFound, post.StatusCode); + Assert.Null(SetCookieHeader(post, CookieName)); + } + } + + Assert.Empty(await Rows(collection, "gone")); + Assert.Empty(await Rows(collection, "alpha")); + Assert.False(visitor.Jar.ContainsKey(CookieName)); + } + + [Fact] + public async Task Page_shows_counts_by_class_and_no_voter_identity() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + var memberId = Guid.NewGuid(); + var accountId = Guid.NewGuid(); + await AddRows(collection, "alpha", + Row("device:canary-device-id-4b1d", VoterClass.Anonymous, ConceptStance.Up, null, "canarytag-aaaa"), + Row("device:canary-device-id-5c2e", VoterClass.Anonymous, ConceptStance.Down, null, "canarytag-bbbb"), + Row("account:" + memberId.ToString("N"), VoterClass.Member, ConceptStance.Up, memberId, "canarytag-cccc"), + Row("account:" + accountId.ToString("N"), VoterClass.Account, ConceptStance.Down, accountId, "canarytag-dddd")); + var visitor = NewVisitor(anonymous: true); + using (await visitor.Post(collection, "alpha", "Up")) + { + } + + using var page = await visitor.Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + + var (everyone, members) = Counts(html); + Assert.Equal("3 up, 2 down", everyone); + Assert.Equal("1 up, 0 down", members); + Assert.Contains("Your vote: up.", html, StringComparison.Ordinal); + + var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => x.UserAccountId is null && !x.VoterKey.Contains("canary")); + var forbidden = new List + { + "device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey, + visitor.Jar[CookieName], + memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D") + }; + foreach (var value in forbidden) + { + Assert.DoesNotContain(value, html, StringComparison.OrdinalIgnoreCase); + } + + Assert.True(page.Headers.CacheControl is { NoStore: true } or { NoCache: true }); + + var signedIn = NewVisitor(anonymous: false); + using var signedInPage = await signedIn.Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + using var signedInCast = await signedIn.Post(collection, "alpha", "Down"); + var accountRow = Assert.Single( + await Rows(collection, "alpha"), + x => x.UserAccountId is { } id && id != memberId && id != accountId); + using var afterCast = await signedIn.Get(collection, "alpha"); + var afterHtml = await afterCast.Content.ReadAsStringAsync(); + Assert.Contains("Your vote: down.", afterHtml, StringComparison.Ordinal); + foreach (var text in new[] { signedInHtml, afterHtml }) + { + Assert.DoesNotContain("account:", text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("N"), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("D"), text, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task Cookie_notice_is_shown_to_anonymous_visitors_and_the_page_links_back_to_the_mill() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var anonymousPage = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await anonymousPage.Content.ReadAsStringAsync(); + using var signedInPage = await NewVisitor(anonymous: false).Get(collection, "alpha"); + var signedInHtml = await signedInPage.Content.ReadAsStringAsync(); + + Assert.Contains("only purpose is to keep your one vote yours", html, StringComparison.Ordinal); + Assert.Contains("lasts a year", html, StringComparison.Ordinal); + Assert.Contains("carries nothing else", html, StringComparison.Ordinal); + Assert.DoesNotContain("only purpose is to keep your one vote yours", signedInHtml, StringComparison.Ordinal); + Assert.Contains( + "href=\"https://aetheria.gamecult.org/Game-Design/Concept-Mill#alpha\"", + html, + StringComparison.Ordinal); + } + + [Fact] + public async Task Page_loads_no_script_and_posts_with_plain_forms() + { + var collection = NewCollection(); + await AddSubject(collection, "alpha"); + + using var page = await NewVisitor(anonymous: true).Get(collection, "alpha"); + var html = await page.Content.ReadAsStringAsync(); + var main = html[html.IndexOf("", StringComparison.Ordinal)]; + + Assert.DoesNotContain(" kind switch + { + Forgery.OneCharacterChanged => genuine[..(genuine.Length / 2)] + + (genuine[genuine.Length / 2] == 'A' ? 'B' : 'A') + + genuine[(genuine.Length / 2 + 1)..], + Forgery.BareDeviceId => deviceId, + Forgery.SameKeyRingOtherPurpose => _factory.Services.GetRequiredService() + .CreateProtector(Purpose + ".other").Protect(deviceId), + Forgery.OtherKeyRingSamePurpose => new EphemeralDataProtectionProvider() + .CreateProtector(Purpose).Protect(deviceId), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static string NewCollection() => "c" + Guid.NewGuid().ToString("N"); + + private Visitor NewVisitor(bool anonymous, WebApplicationFactory? factory = null) => + new((factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri("https://localhost") + }), anonymous); + + private static string? SetCookieHeader(HttpResponseMessage response, string name) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal)) + : null; + + private static (string Everyone, string Members) Counts(string html) + { + var cells = DefinitionCell().Matches(html).Select(m => m.Groups[1].Value.Trim()).ToArray(); + Assert.Equal(2, cells.Length); + return (cells[0], cells[1]); + } + + [GeneratedRegex("
(.*?)
", RegexOptions.Singleline)] + private static partial Regex DefinitionCell(); + + private async Task AddSubject(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = key, + Title = key, + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + } + + private async Task Retire(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + subject.RetiredAtUtc = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(); + } + + private static ConceptAppraisal Row(string key, VoterClass voterClass, ConceptStance stance, Guid? accountId, string tag) => + new() + { + VoterKey = key, + VoterClass = voterClass, + Stance = stance, + UserAccountId = accountId, + NetworkTag = tag, + CastAtUtc = DateTimeOffset.UtcNow + }; + + private async Task AddRows(string collection, string key, params ConceptAppraisal[] rows) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key); + foreach (var row in rows) + { + row.SubjectId = subject.Id; + db.ConceptAppraisals.Add(row); + } + + await db.SaveChangesAsync(); + } + + // A fresh DbContext per read: what a later request would see, not what this test's tracker remembers. + private async Task> Rows(string collection, string key) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection && x.Subject.Key == key) + .ToListAsync(); + } + + private sealed class Visitor(HttpClient client, bool anonymous) + { + public Dictionary Jar { get; } = new(StringComparer.Ordinal); + + public Task Get(string collection, string key) => + Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}")); + + // A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it. + public Task Post( + string collection, + string key, + string? handler, + string? origin = SiteOrigin, + string? fetchSite = null, + HttpMethod? method = null) + { + var request = new HttpRequestMessage( + method ?? HttpMethod.Post, + $"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler)) + { + Content = new FormUrlEncodedContent([]) + }; + if (origin is not null) + { + request.Headers.TryAddWithoutValidation("Origin", origin); + } + + if (fetchSite is not null) + { + request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite); + } + + return Send(request); + } + + public async Task Send(HttpRequestMessage request) + { + if (anonymous) + { + request.Headers.Add("X-Test-Anonymous", "true"); + } + + if (Jar.Count > 0) + { + request.Headers.TryAddWithoutValidation( + "Cookie", + string.Join("; ", Jar.Select(x => x.Key + "=" + x.Value))); + } + + var response = await client.SendAsync(request); + if (response.Headers.TryGetValues("Set-Cookie", out var values)) + { + foreach (var header in values) + { + var pair = header.Split(';')[0]; + var split = pair.IndexOf('='); + Jar[pair[..split]] = pair[(split + 1)..]; + } + } + + return response; + } + } +} diff --git a/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs new file mode 100644 index 0000000..7510ed8 --- /dev/null +++ b/tests/Bifrost.Web.Tests/ConceptVoteRateLimitTests.cs @@ -0,0 +1,764 @@ +using Bifrost.Web.Features.Shared; +using System.Net; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bifrost.Web.Tests; + +// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in +// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour. +public sealed class ConceptVoteRateLimitTests : IClassFixture +{ + private const string Site = "https://localhost"; + private const string CookieName = BifrostCookies.Voter; + + private readonly LimitedFactory _factory; + + public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory; + + [Fact] + public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.10"); + foreach (var handler in new[] { "Up", "Down", "Up" }) + { + using var ok = await device.Post(collection, handler); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var other = new Client(_factory, "203.0.113.11"); + using var unaffected = await other.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task Retract_spends_the_cast_budget() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.12"); + await device.Post(collection, "Up"); + await device.Post(collection, "Retract"); + await device.Post(collection, "Retract"); + + using var denied = await device.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not() + { + var collection = await NewSubject(); + var first = new Client(_factory, "2001:db8:1:100::1"); + var sameBlock = new Client(_factory, "2001:db8:1:1ff::2"); + await first.Post(collection, "Up"); + await first.Post(collection, "Down"); + using var third = await sameBlock.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await sameBlock.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var elsewhere = new Client(_factory, "2001:db8:1:200::1"); + using var unaffected = await elsewhere.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode); + } + + [Fact] + public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it() + { + var collection = await NewSubject(); + var device = new Client(_factory, "203.0.113.13"); + for (var i = 0; i < 3; i++) + { + await device.Post(collection, "Up"); + } + + using (var first = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode); + Assert.Equal("600", RetryAfter(first)); + } + + // 499.5 s remain: Retry-After rounds up, so a client told to wait never retries early. + _factory.Clock.Advance(TimeSpan.FromMilliseconds(100_500)); + using (var later = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode); + Assert.Equal("500", RetryAfter(later)); + } + + _factory.Clock.Advance(TimeSpan.FromMilliseconds(498_500)); + using (var last = await device.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode); + Assert.Equal("1", RetryAfter(last)); + } + + _factory.Clock.Advance(TimeSpan.FromSeconds(1)); + using var reopened = await device.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket() + { + var collection = await NewSubject(); + var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" }; + for (var i = 0; i < 3; i++) + { + using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Signed_in_accounts_are_limited_per_account() + { + var collection = await NewSubject(); + var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") }; + var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") }; + var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") }; + var visitor = new Client(_factory, "203.0.113.20"); + + await alice.Post(collection, "Up"); + await alice.Post(collection, "Down"); + await aliceElsewhere.Post(collection, "Up"); + using var denied = await aliceElsewhere.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + + var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") }; + using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down"); + Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode); + + using var bobOk = await bob.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode); + using var visitorOk = await visitor.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode); + } + + [Fact] + public async Task Retract_by_a_signed_in_account_spends_the_account_budget_from_any_prefix() + { + var collection = await NewSubject(); + var home = new Client(_factory, "203.0.113.60") { Account = ("7003", "carol-limits") }; + var away = new Client(_factory, "203.0.113.61") { Account = ("7003", "carol-limits") }; + var elsewhere = new Client(_factory, "203.0.113.62") { Account = ("7003", "carol-limits") }; + await home.Post(collection, "Up"); + await home.Post(collection, "Down"); + using var third = await away.Post(collection, "Retract"); + Assert.Equal(HttpStatusCode.Redirect, third.StatusCode); + + using var denied = await elsewhere.Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task Mint_budget_is_spent_only_by_minting_casts() + { + var collection = await NewSubject(); + const string prefix = "203.0.113.30"; + var first = new Client(_factory, prefix); + using (var mint = await first.Post(collection, "Up")) + { + Assert.NotNull(SetCookie(mint)); + } + + // Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint. + using (var recast = await first.Post(collection, "Down")) + { + Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode); + Assert.Null(SetCookie(recast)); + } + + using (var recastAgain = await first.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode); + } + + _factory.Clock.Advance(TimeSpan.FromMinutes(10)); + var second = new Client(_factory, prefix); + using (var secondMint = await second.Post(collection, "Up")) + { + Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode); + Assert.NotNull(SetCookie(secondMint)); + } + + var rowsBefore = (await Rows(collection)).Count; + var third = new Client(_factory, prefix); + using var denied = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Null(SetCookie(denied)); + Assert.Equal(rowsBefore, (await Rows(collection)).Count); + Assert.StartsWith("text/plain", denied.Content.Headers.ContentType?.MediaType); + var refusal = await denied.Content.ReadAsStringAsync(); + Assert.Contains("sign in", refusal, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113", refusal); + + // An hour after the first mint the budget is whole again. + _factory.Clock.Advance(TimeSpan.FromMinutes(50)); + using var reopened = await third.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode); + } + + [Fact] + public async Task Anonymous_cast_under_the_limit_succeeds() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.40"); + + using var response = await visitor.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var row = Assert.Single(await Rows(collection)); + Assert.Equal(VoterClass.Anonymous, row.VoterClass); + Assert.Equal(ConceptStance.Up, row.Stance); + } + + [Fact] + public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address() + { + var logs = new CapturingLoggerProvider(); + var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs)))); + var collection = await NewSubject(); + var visitor = new Client(factory, "203.0.113.77"); + for (var i = 0; i < 3; i++) + { + await visitor.Post(collection, i == 1 ? "Down" : "Up"); + } + + var before = Assert.Single(await Rows(collection)); + using var denied = await visitor.Post(collection, "Down"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.NotNull(RetryAfter(denied)); + Assert.Null(SetCookie(denied)); + var after = Assert.Single(await Rows(collection)); + Assert.Equal(before.Stance, after.Stance); + var body = await denied.Content.ReadAsStringAsync(); + Assert.Contains("too fast", body); + var seen = body + + string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))); + Assert.DoesNotContain("203.0.113", seen); + Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal)); + } + + [Fact] + public void Defaults_allow_thirty_new_devices_an_hour_per_address() + { + var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions(); + + Assert.Equal(30, defaults.MintPermits); + Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow); + Assert.Equal(100_000, defaults.MaxTrackedKeys); + } + + [Fact] + public void Every_table_holds_at_most_the_configured_keys_and_accounts_have_a_table_of_their_own() + { + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create( + new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1, CastPermits = 1, MintPermits = 1 }), + new TestClock()); + var account = new VoterIdentity("account:test", VoterClass.Account, Guid.NewGuid()); + + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)!.Value.TableFull); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.2"), account)); + var otherAccount = new VoterIdentity("account:other", VoterClass.Account, Guid.NewGuid()); + Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), otherAccount)!.Value.TableFull); + Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1"))); + Assert.True(limits.DenyMint(IPAddress.Parse("198.51.100.2"))!.Value.TableFull); + } + + [Fact] + public async Task Filling_the_table_with_unspent_keys_locks_out_neither_a_fresh_visitor_nor_a_fresh_account() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + using var filler = await new Client(factory, $"203.0.113.{80 + i}").Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, filler.StatusCode); + } + + using var visitor = await new Client(factory, "203.0.113.90").Post(collection, "Up"); + using var account = await new Client(factory, "203.0.113.91") { Account = ("7101", "fresh-account") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.Redirect, visitor.StatusCode); + Assert.NotNull(SetCookie(visitor)); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + } + + [Fact] + public async Task A_spent_key_survives_eviction_pressure_and_stays_refused_while_rotating_callers_succeed() + { + var factory = Capped(4); + var collection = await NewSubject(); + var spender = new Client(factory, "203.0.113.100"); + for (var i = 0; i < 3; i++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + for (var i = 0; i < 12; i++) + { + using var rotating = await new Client(factory, $"203.0.114.{i + 1}").Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, rotating.StatusCode); + } + + using var denied = await spender.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + Assert.Contains("too fast", await denied.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task A_rotating_caller_wins_back_only_a_forgotten_keys_unspent_budget() + { + var factory = Capped(2); + var collection = await NewSubject(); + var forgotten = new Client(factory, "203.0.113.110"); + await forgotten.Post(collection, "Up"); + await forgotten.Post(collection, "Up"); + var recent = new Client(factory, "203.0.113.111"); + await recent.Post(collection, "Up"); + await new Client(factory, "203.0.113.112").Post(collection, "Up"); + + // forgotten was least recently used and had one permit left: it was evicted, so it has all three again, no more. + for (var i = 0; i < 3; i++) + { + using var ok = await forgotten.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await forgotten.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public async Task A_table_full_of_spent_keys_refuses_a_new_visitor_with_its_true_reason_and_leaves_accounts_alone() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{120 + i}"); + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + var rowsBefore = (await Rows(collection)).Count; + using var visitor = await new Client(factory, "203.0.113.130").Post(collection, "Up"); + using var account = await new Client(factory, "203.0.113.131") { Account = ("7102", "spared-account") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, visitor.StatusCode); + Assert.NotNull(RetryAfter(visitor)); + Assert.Null(SetCookie(visitor)); + var body = await visitor.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("too fast", body); + Assert.Contains("sign in", body, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("203.0.113", body + string.Join(";", visitor.Headers.Select(h => string.Join(",", h.Value)))); + Assert.Equal(HttpStatusCode.Redirect, account.StatusCode); + Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count); + } + + [Fact] + public async Task A_full_account_table_refuses_a_new_account_as_busy_without_offering_sign_in() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{150 + i}") { Account = ($"72{i:00}", $"spent-account-{i}") }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + using var fresh = await new Client(factory, "203.0.113.160") { Account = ("7299", "fresh-signed-in") } + .Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode); + var body = await fresh.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task A_signed_in_retract_on_a_full_account_table_is_busy_without_offering_sign_in() + { + var factory = Capped(4); + var collection = await NewSubject(); + for (var i = 0; i < 4; i++) + { + var spender = new Client(factory, $"203.0.113.{170 + i}") { Account = ($"73{i:00}", $"retract-spent-{i}") }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await spender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + } + + using var fresh = await new Client(factory, "203.0.113.180") { Account = ("7399", "fresh-retractor") } + .Post(collection, "Retract"); + + Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode); + var body = await fresh.Content.ReadAsStringAsync(); + Assert.Contains("busy", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_spent_cast_budget_says_only_that_voting_is_too_fast_never_offering_sign_in(bool signedIn) + { + var factory = Capped(1000); + var collection = await NewSubject(); + var voter = new Client(factory, "203.0.113.190") + { + Account = signedIn ? ("7400", "fast-signed-in") : null + }; + for (var cast = 0; cast < 3; cast++) + { + using var ok = await voter.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var spent = await voter.Post(collection, "Up"); + + Assert.Equal(HttpStatusCode.TooManyRequests, spent.StatusCode); + var body = await spent.Content.ReadAsStringAsync(); + Assert.Contains("too fast", body); + Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("busy", body); + } + + [Fact] + public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device() + { + var collection = await NewSubject(); + var sender = new Client(_factory, "203.0.113.140"); + foreach (var handler in new[] { "Up", "Down", "Retract" }) + { + for (var i = 0; i < 4; i++) + { + using var missing = await sender.Post("c" + Guid.NewGuid().ToString("N"), handler); + Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + Assert.Null(SetCookie(missing)); + } + } + + for (var i = 0; i < 3; i++) + { + using var ok = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode); + } + + using var denied = await sender.Post(collection, "Up"); + Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode); + } + + [Fact] + public void A_request_with_no_client_address_shares_one_bucket_apart_from_every_address() + { + var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits( + Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { CastPermits = 1 }), + new TestClock()); + + Assert.Null(limits.DenyCast(null, null)); + Assert.NotNull(limits.DenyCast(null, null)); + Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null)); + } + + [Fact] + public async Task Reads_are_not_under_the_vote_budgets() + { + var collection = await NewSubject(); + var visitor = new Client(_factory, "203.0.113.50"); + for (var i = 0; i < 4; i++) + { + await visitor.Post(collection, "Up"); + } + + using var page = await visitor.Get($"/mill/{collection}/alpha"); + using var export = await visitor.Get($"/public/concepts/{collection}/tallies"); + + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + Assert.Equal(HttpStatusCode.OK, export.StatusCode); + } + + // The host itself, not the validator alone: Program runs StartupConfigurationValidator on every start, in every + // environment. IPv4 is 0-32 and IPv6 is 0-128 inclusive; a length outside its own family is refused by name. + [Theory] + [InlineData("Development", 33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData("Development", -1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData("Development", 32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData("Development", 32, -1, "ConceptVotes:IPv6PrefixLength")] + [InlineData("Testing", 33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData("Testing", -1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData("Testing", 32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData("Testing", 32, -1, "ConceptVotes:IPv6PrefixLength")] + [InlineData("Production", 33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData("Production", -1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData("Production", 32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData("Production", 32, -1, "ConceptVotes:IPv6PrefixLength")] + public void Host_refuses_to_start_with_a_prefix_length_outside_its_address_family( + string environment, int v4, int v6, string key) + { + using var factory = WithPrefixes(environment, v4, v6); + + var failure = Assert.ThrowsAny(() => 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 + { + ["ConceptVotes:MaxTrackedKeys"] = maxTrackedKeys.ToString(System.Globalization.CultureInfo.InvariantCulture) + }))); + + private static string? RetryAfter(HttpResponseMessage response) => + response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null; + + private static string? SetCookie(HttpResponseMessage response) => + response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal)) + : null; + + 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(); + var db = scope.ServiceProvider.GetRequiredService(); + db.ConceptSubjects.Add(new ConceptSubject + { + Collection = collection, + Key = "alpha", + Title = "alpha", + RegisteredAtUtc = DateTimeOffset.UtcNow + }); + await db.SaveChangesAsync(); + return collection; + } + + private async Task> Rows(string collection) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.ConceptAppraisals.AsNoTracking() + .Where(x => x.Subject!.Collection == collection) + .ToListAsync(); + } + + // One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account. + private sealed class Client(WebApplicationFactory factory, string address) + { + private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false, + BaseAddress = new Uri(Site) + }); + + private string? _cookie; + + public string Peer { get; init; } = "127.0.0.1"; + + public (string Id, string Login)? Account { get; init; } + + public Task Post(string collection, string handler, string? forwardedFor = null) => + Send( + new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}") + { + Content = new FormUrlEncodedContent([]) + }, + forwardedFor); + + public Task Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null); + + private async Task Send(HttpRequestMessage request, string? forwardedFor) + { + request.Headers.TryAddWithoutValidation("Origin", Site); + request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address); + request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer); + if (Account is { } account) + { + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id); + request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login); + } + else + { + request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true"); + } + + if (_cookie is not null) + { + request.Headers.TryAddWithoutValidation("Cookie", _cookie); + } + + var response = await _http.SendAsync(request); + if (SetCookie(response) is { } set) + { + _cookie = set.Split(';')[0]; + } + + return response; + } + } + + public sealed class LimitedFactory : TestWebApplicationFactory + { + public TestClock Clock { get; } = new(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureAppConfiguration((_, configuration) => + configuration.AddInMemoryCollection(new Dictionary + { + ["ConceptVotes:CastPermits"] = "3", + ["ConceptVotes:CastWindow"] = "00:10:00", + ["ConceptVotes:MintPermits"] = "2", + ["ConceptVotes:MintWindow"] = "01:00:00", + ["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789" + })); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(Clock); + services.AddTransient(); + }); + } + } + + public sealed class TestClock : TimeProvider + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + private long _ticks = 5_000_000; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _now; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _now += by; + _ticks += by.Ticks; + } + } + + // The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline. + private sealed class PeerFilter : IStartupFilter + { + public Action Configure(Action next) => app => + { + app.Use((context, nextMiddleware) => + { + context.Connection.RemoteIpAddress = + IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1"); + return nextMiddleware(context); + }); + next(app); + }; + } +} 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/CookieHardeningTests.cs b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs new file mode 100644 index 0000000..287e8ac --- /dev/null +++ b/tests/Bifrost.Web.Tests/CookieHardeningTests.cs @@ -0,0 +1,100 @@ +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Heimdall; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +// Every cookie is host-only: __Host- name, Secure, Path=/, no Domain. These pin the literals on purpose, so removing +// the prefix, Secure or Path=/ from any of them fails here and not only in a test that reads the same constant. +public sealed class CookieHardeningTests(CookieHardeningTests.OAuthConfiguredFactory factory) + : IClassFixture +{ + // Program reads GitHubOAuth at startup, before the base factory's in-memory settings land, so the GitHub scheme + // is only registered when the settings arrive as host settings. + public sealed class OAuthConfiguredFactory : TestWebApplicationFactory + { + protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder) + { + builder.UseSetting("GitHubOAuth:ClientId", "configured-for-tests"); + builder.UseSetting("GitHubOAuth:ClientSecret", "configured-for-tests"); + base.ConfigureWebHost(builder); + } + } + + [Fact] + public void Cookie_names_carry_the_host_prefix_and_live_at_the_root() + { + Assert.Equal("__Host-bifrost_session", BifrostCookies.Session); + Assert.Equal("__Host-bifrost_oauth_correlation", BifrostCookies.OAuthCorrelation); + Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery); + Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName); + Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix); + Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal)); + Assert.Equal("/", BifrostCookies.Path); + Assert.Equal("/", VoterKeyResolver.CookiePath); + } + + [Fact] + public void Session_cookie_is_host_only() + { + var cookie = factory.Services.GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme).Cookie; + AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain); + Assert.True(cookie.HttpOnly); + } + + [Fact] + public void Oauth_correlation_cookie_is_host_only() + { + var cookie = factory.Services.GetRequiredService>().Get("GitHub").CorrelationCookie; + AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain); + Assert.True(cookie.HttpOnly); + } + + [Fact] + public void Antiforgery_cookie_is_host_only() + { + var cookie = factory.Services.GetRequiredService>().Value.Cookie; + AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain); + } + + [Fact] + public void Voter_and_attempt_cookies_set_secure_root_path_and_no_domain() + { + var context = new DefaultHttpContext(); + new VoterKeyResolver(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()).Mint(context); + context.Response.Cookies.Append("__Host-bifrost_heimdall_0", "v", HeimdallAttemptCookie.Options()); + var deletion = new DefaultHttpContext(); + deletion.Response.Cookies.Delete("__Host-bifrost_heimdall_0", HeimdallAttemptCookie.DeleteOptions()); + + var headers = context.Response.Headers.SetCookie.Concat(deletion.Response.Headers.SetCookie).ToArray(); + Assert.Equal(3, headers.Length); + foreach (var header in headers) + { + var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray(); + Assert.StartsWith("__host-", attributes[0], StringComparison.Ordinal); + Assert.Contains("path=/", attributes); + Assert.DoesNotContain(attributes, x => x.StartsWith("domain", StringComparison.Ordinal)); + } + + // A browser ignores a __Host- Set-Cookie that lacks Secure, a deletion included. + Assert.All(headers, header => Assert.Contains("secure", header!.Split(';').Select(x => x.Trim().ToLowerInvariant()))); + Assert.All(headers.Take(2), header => + Assert.Contains("httponly", header!.Split(';').Select(x => x.Trim().ToLowerInvariant()))); + } + + private static void AssertHostOnly(string? name, CookieSecurePolicy secure, string? path, string? domain) + { + Assert.StartsWith("__Host-", name, StringComparison.Ordinal); + Assert.Equal(CookieSecurePolicy.Always, secure); + Assert.Equal("/", path); + Assert.Null(domain); + } +} 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/FixedWindowBudgetTests.cs b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs new file mode 100644 index 0000000..e2fedb2 --- /dev/null +++ b/tests/Bifrost.Web.Tests/FixedWindowBudgetTests.cs @@ -0,0 +1,299 @@ +using Bifrost.Web.Features.Shared; + +namespace Bifrost.Web.Tests; + +public sealed class FixedWindowBudgetTests +{ + private static readonly TimeSpan Window = TimeSpan.FromSeconds(60); + private const int Cap = 1000; + + [Fact] + public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, Cap, clock); + + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out var wait)); + + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Assert.True(Try(budget, "a", out _)); + + clock.Advance(Window - TimeSpan.FromTicks(1)); + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(TimeSpan.FromTicks(1), wait); + + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_denied_attempt_does_not_extend_the_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + for (var i = 0; i < 5; i++) + { + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.False(Try(budget, "a", out _)); + } + + clock.Advance(TimeSpan.FromSeconds(10)); + Assert.True(Try(budget, "a", out _)); + } + + [Fact] + public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(30)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(30)); + + // At 60 s a has ended and b has 30 s left. + Try(budget, "c", out _); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(Try(budget, "b", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(30), wait); + + clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1)); + Assert.False(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromTicks(1)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(10)); + clock.StepWall(-TimeSpan.FromHours(1)); + + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + clock.Advance(TimeSpan.FromSeconds(50)); + Assert.True(Try(budget, "a", out _)); + Assert.Equal(1, budget.TrackedKeys); + } + + [Fact] + public void A_wall_clock_step_forward_does_not_reset_a_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + Try(budget, "a", out _); + clock.StepWall(TimeSpan.FromHours(1)); + + Assert.False(Try(budget, "a", out var wait)); + Assert.Equal(Window, wait); + } + + [Fact] + public void A_new_key_at_the_cap_evicts_the_least_recently_used_key_with_permits_left() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(3, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(1)); + Assert.True(Try(budget, "a", out _)); + + // b was used least recently, though a began first: c takes b's place and a keeps its count. + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void A_forgotten_key_regains_only_what_it_had_not_spent_and_a_spent_key_regains_nothing() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "spent", out _)); + Assert.True(Try(budget, "partial", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + // Rotating callers evict partial, never spent. + for (var i = 0; i < 10; i++) + { + Assert.True(Try(budget, "rot" + i, out _)); + } + + Assert.False(Try(budget, "spent", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(50), wait); + Assert.Equal(2, budget.TrackedKeys); + + // partial came back whole (2 permits), which is all a rotating caller can win: its own forgotten budget. + Assert.True(Try(budget, "partial", out _)); + } + + [Fact] + public void A_key_that_spends_its_last_permit_leaves_the_evictable_set() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + + // a is spent and b is not: the newcomer evicts b although a was used less recently. + Assert.True(Try(budget, "c", out _)); + Assert.False(Try(budget, "a", out _)); + Assert.True(Try(budget, "b", out _)); + } + + [Fact] + public void A_table_full_of_spent_keys_refuses_a_new_key_as_table_full_with_the_wait_for_the_oldest_window() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, 2, clock); + Assert.True(Try(budget, "a", out _)); + clock.Advance(TimeSpan.FromSeconds(20)); + Assert.True(Try(budget, "b", out _)); + clock.Advance(TimeSpan.FromSeconds(10)); + + var denial = budget.Spend("c"); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), true), denial); + Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), false), budget.Spend("a")); + Assert.Equal(2, budget.TrackedKeys); + + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(Try(budget, "c", out _)); + Assert.Equal(2, budget.TrackedKeys); + Assert.False(Try(budget, "b", out var wait)); + Assert.Equal(TimeSpan.FromSeconds(20), wait); + } + + [Fact] + public void An_evicted_key_leaves_the_expiry_list_so_its_old_window_end_cannot_remove_the_key_that_took_its_name() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 2, clock); + Try(budget, "a", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "b", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "c", out _); + clock.Advance(TimeSpan.FromSeconds(1)); + Try(budget, "a", out _); + + // The first a began at 0 and ended at 60; the a that returned at 3 is still inside its window. + clock.Advance(TimeSpan.FromSeconds(57)); + Try(budget, "c", out _); + + Assert.Equal(2, budget.TrackedKeys); + } + + [Fact] + public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 1, clock); + Assert.True(Try(budget, "a", out _)); + + Assert.True(Try(budget, "a", out _)); + Assert.False(Try(budget, "a", out _)); + } + + [Fact] + public void Eviction_work_does_not_scan_the_table() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(2, Window, 50_000, clock); + for (var i = 0; i < 50_000; i++) + { + budget.Spend("k" + i); + } + + var started = System.Diagnostics.Stopwatch.StartNew(); + for (var i = 0; i < 200_000; i++) + { + budget.Spend("n" + i); + } + + Assert.Equal(50_000, budget.TrackedKeys); + Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"200000 evicting inserts took {started.Elapsed}"); + } + + [Fact] + public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen() + { + var clock = new Clock(); + var budget = new FixedWindowBudget(1, Window, Cap, clock); + for (var i = 0; i < 100; i++) + { + Try(budget, "k" + i, out _); + } + + Assert.Equal(100, budget.TrackedKeys); + clock.Advance(Window); + Try(budget, "fresh", out _); + + Assert.Equal(1, budget.TrackedKeys); + } + + [Theory] + [InlineData(0, 60)] + [InlineData(-1, 60)] + [InlineData(1, 0)] + [InlineData(1, -5)] + public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds) + { + Assert.ThrowsAny( + () => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock())); + } + + [Fact] + public void A_cap_below_one_is_refused() + { + Assert.Throws(() => new FixedWindowBudget(1, Window, 0, new Clock())); + } + + private static bool Try(FixedWindowBudget budget, string key, out TimeSpan wait) + { + var denial = budget.Spend(key); + wait = denial?.RetryAfter ?? TimeSpan.Zero; + return denial is null; + } + + // Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would. + private sealed class Clock : TimeProvider + { + private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero); + private long _ticks = 5_000_000; + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _wall; + + public override long GetTimestamp() => _ticks; + + public void Advance(TimeSpan by) + { + _wall += by; + _ticks += by.Ticks; + } + + public void StepWall(TimeSpan by) => _wall += by; + } +} 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 new file mode 100644 index 0000000..4ae3eae --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/complete-page-harness.cjs @@ -0,0 +1,69 @@ +// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints +// what it did as JSON: the ordered events, and the address and form values at the moment of the submit. +// Usage: node complete-page-harness.cjs +const fs = require("fs"); +const vm = require("vm"); + +const html = fs.readFileSync(process.argv[2], "utf8"); +const scripts = [...html.matchAll(/ 
 &", + "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/mill-three-ships.json b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json new file mode 100644 index 0000000..bde47d0 --- /dev/null +++ b/tests/Bifrost.Web.Tests/Fixtures/mill-three-ships.json @@ -0,0 +1,9 @@ +{ + "sections": [], + "votes": {}, + "ships": [ + { "id": "bison", "name": "Bison", "code": "AU", "role": "Swarm escort" }, + { "id": "ibis-mk2", "name": "Ibis Mk II", "code": "AU" }, + { "id": "kestrel", "name": "Kestrel" } + ] +} 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 new file mode 100644 index 0000000..8ad0a0a --- /dev/null +++ b/tests/Bifrost.Web.Tests/HostTrustTests.cs @@ -0,0 +1,409 @@ +using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Shared; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Bifrost.Web.Tests; + +public sealed class HostTrustTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public HostTrustTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Theory] + [InlineData("127.0.0.1")] + [InlineData("::1")] + [InlineData("::ffff:127.0.0.1")] + public async Task Forwarded_for_from_loopback_peer_becomes_remote_ip(string peer) + { + var context = await RunForwardedHeaders(peer, "203.0.113.7", "https"); + + Assert.Equal(IPAddress.Parse("203.0.113.7"), context.Connection.RemoteIpAddress); + Assert.Equal("https", context.Request.Scheme); + } + + [Fact] + public async Task Forwarded_for_from_non_loopback_peer_is_ignored() + { + var context = await RunForwardedHeaders("198.51.100.9", "203.0.113.7", "https"); + + Assert.Equal(IPAddress.Parse("198.51.100.9"), context.Connection.RemoteIpAddress); + Assert.Equal("http", context.Request.Scheme); + } + + [Fact] + public async Task Only_the_rightmost_forwarded_entry_is_used() + { + var context = await RunForwardedHeaders("127.0.0.1", "6.6.6.6, 203.0.113.7", null); + + Assert.Equal(IPAddress.Parse("203.0.113.7"), context.Connection.RemoteIpAddress); + } + + [Fact] + public async Task A_loopback_forwarded_entry_does_not_chain_to_earlier_entries() + { + var context = await RunForwardedHeaders("127.0.0.1", "6.6.6.6, 127.0.0.1", null); + + Assert.Equal(IPAddress.Loopback, context.Connection.RemoteIpAddress); + } + + [Fact] + public async Task Only_the_exact_loopback_addresses_are_trusted_not_the_loopback_range() + { + var context = await RunForwardedHeaders("127.0.0.2", "203.0.113.7", null); + + Assert.Equal(IPAddress.Parse("127.0.0.2"), context.Connection.RemoteIpAddress); + } + + [Fact] + public void Payload_protected_under_one_content_root_unprotects_under_another() + { + var root = NewRoot(); + var keys = Path.Combine(root, "keys"); + try + { + using var hostA = WithContentRoot(MakeRelease(root, "release-a"), keys); + using var hostB = WithContentRoot(MakeRelease(root, "release-b"), keys); + + var sealedPayload = Protect(hostA, "payload"); + + Assert.Equal("payload", Unprotect(hostB, sealedPayload)); + Assert.NotEmpty(Directory.GetFiles(keys, "key-*.xml")); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void A_different_keys_path_does_not_unprotect_the_payload() + { + var root = NewRoot(); + try + { + using var hostA = WithContentRoot(MakeRelease(root, "release-a"), Path.Combine(root, "keys-a")); + using var hostB = WithContentRoot(MakeRelease(root, "release-b"), Path.Combine(root, "keys-b")); + + var sealedPayload = Protect(hostA, "payload"); + + Assert.ThrowsAny( + () => Unprotect(hostB, sealedPayload)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void The_container_image_does_not_set_the_forwarded_headers_switch() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Dockerfile"))) + { + directory = directory.Parent; + } + + Assert.NotNull(directory); + Assert.DoesNotContain( + "ASPNETCORE_FORWARDEDHEADERS_ENABLED", + File.ReadAllText(Path.Combine(directory!.FullName, "Dockerfile"))); + } + + [Fact] + public void Production_without_keys_path_fails_startup_validation() + { + var withoutKey = Assert.Throws(() => Validate(keysPath: null)); + Assert.Contains("DataProtection:KeysPath", withoutKey.Message); + + Validate(keysPath: "/not/echoed/anywhere"); + } + + [Fact] + public void Startup_refusal_names_the_key_and_never_echoes_configured_values() + { + var canary = "canary-secret-4f1e"; + var refusal = Assert.Throws( + () => Validate(keysPath: null, connectionString: canary)); + + Assert.Contains("DataProtection:KeysPath", refusal.Message); + 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() + { + var canary = "canary-keys-path-77aa"; + var refusal = Assert.Throws( + () => Validate(keysPath: canary, tagSecret: " ")); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(canary, refusal.Message); + Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message); + } + + [Theory] + [InlineData("short-secret")] + [InlineData("0123456789012345678901234567890")] + public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", tagSecret: secret)); + + Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message); + Assert.DoesNotContain(secret, refusal.Message); + Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901"); + } + + [Theory] + [InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")] + [InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")] + [InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")] + public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6)); + + Assert.Contains(key, refusal.Message); + Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128); + Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0); + } + + [Theory] + [InlineData("Development")] + [InlineData("Testing")] + [InlineData("Production")] + public void Every_environment_refuses_prefix_lengths_outside_the_address_family(string environment) + { + var v4 = Assert.Throws( + () => Validate(keysPath: "/keys", environment: environment, ipv4Length: 33)); + Assert.Contains("ConceptVotes:IPv4PrefixLength", v4.Message); + Assert.DoesNotContain("IPv6PrefixLength", v4.Message); + + var v6 = Assert.Throws( + () => Validate(keysPath: "/keys", environment: environment, ipv6Length: 129)); + Assert.Contains("ConceptVotes:IPv6PrefixLength", v6.Message); + Assert.DoesNotContain("IPv4PrefixLength", v6.Message); + + Validate(keysPath: "/keys", environment: environment, ipv4Length: 0, ipv6Length: 0); + } + + [Theory] + [InlineData("Development", 0, 1, 5, 1, "ConceptVotes:CastPermits")] + [InlineData("Testing", 30, 0, 5, 1, "ConceptVotes:CastPermits")] + [InlineData("Production", 30, 1, 0, 1, "ConceptVotes:MintPermits")] + [InlineData("Production", 30, 1, 5, 0, "ConceptVotes:MintPermits")] + public void Every_environment_refuses_a_vote_budget_that_cannot_be_enforced( + string environment, + int castPermits, + int castMinutes, + int mintPermits, + int mintMinutes, + string key) + { + var refusal = Assert.Throws(() => Validate( + keysPath: "/keys", + environment: environment, + castPermits: castPermits, + castWindow: TimeSpan.FromMinutes(castMinutes), + mintPermits: mintPermits, + mintWindow: TimeSpan.FromMinutes(mintMinutes))); + + Assert.Contains(key, refusal.Message); + Validate(keysPath: "/keys", environment: environment); + } + + [Theory] + [InlineData("Development")] + [InlineData("Testing")] + [InlineData("Production")] + public void Every_environment_refuses_a_vote_table_cap_below_one(string environment) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", environment: environment, maxTrackedKeys: 0)); + + Assert.Contains("ConceptVotes:MaxTrackedKeys", refusal.Message); + } + + [Theory] + [InlineData("*")] + [InlineData("")] + [InlineData("null")] + [InlineData("https://canary-origin-5d1c.example/")] + [InlineData("https://canary-origin-5d1c.example/path")] + [InlineData("https://Canary-Origin-5d1c.example")] + [InlineData("http://canary-origin-5d1c.example")] + [InlineData("canary-origin-5d1c.example")] + [InlineData("https://canary-origin-5d1c.example:443")] + [InlineData("https://user@canary-origin-5d1c.example")] + public void Production_refuses_an_allowed_origin_that_is_not_an_exact_https_origin_without_echoing_it(string origin) + { + var refusal = Assert.Throws( + () => Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", origin])); + + Assert.Contains("ConceptVotes:AllowedOrigins", refusal.Message); + Assert.DoesNotContain("canary-origin-5d1c", refusal.Message, StringComparison.OrdinalIgnoreCase); + Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", "https://other.example:8443"]); + Validate(keysPath: "/keys", allowedOrigins: []); + } + + [Fact] + public void Development_does_not_require_keys_path_or_tag_secret() + { + Validate(keysPath: null, environment: Environments.Development, tagSecret: null); + } + + private async Task RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto) + { + var options = _factory.Services.GetRequiredService>(); + var middleware = new ForwardedHeadersMiddleware( + _ => Task.CompletedTask, + NullLoggerFactory.Instance, + options); + var context = new DefaultHttpContext(); + context.Request.Scheme = "http"; + context.Connection.RemoteIpAddress = IPAddress.Parse(peer); + context.Request.Headers["X-Forwarded-For"] = forwardedFor; + if (forwardedProto is not null) + { + context.Request.Headers["X-Forwarded-Proto"] = forwardedProto; + } + + await middleware.Invoke(context); + return context; + } + + private static string NewRoot() => + Path.Combine(Path.GetTempPath(), "bifrost-dp-" + Guid.NewGuid().ToString("N")); + + private static string MakeRelease(string root, string name) => + Directory.CreateDirectory(Path.Combine(root, name)).FullName; + + private static string Protect(TestWebApplicationFactory host, string value) => + host.Services.GetRequiredService().CreateProtector("P").Protect(value); + + private static string Unprotect(TestWebApplicationFactory host, string value) => + host.Services.GetRequiredService().CreateProtector("P").Unprotect(value); + + private TestWebApplicationFactory WithContentRoot(string contentRoot, string keysPath) => + new KeyRingFactory(contentRoot, keysPath); + + private sealed class KeyRingFactory(string contentRoot, string keysPath) : TestWebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.UseContentRoot(contentRoot); + builder.UseSetting("DataProtection:KeysPath", keysPath); + } + } + + 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, + int castPermits = 30, + TimeSpan? castWindow = null, + int mintPermits = 5, + TimeSpan? mintWindow = null, + int maxTrackedKeys = 100_000) + { + var values = new Dictionary + { + ["AllowedHosts"] = "bifrost.example", + ["ConnectionStrings:Bifrost"] = connectionString, + ["DataProtection:KeysPath"] = keysPath, + }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + var hostEnvironment = new StubHostEnvironment(environment); + + new StartupConfigurationValidator( + configuration, + hostEnvironment, + Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }), + Options.Create(new GitHubAppOptions { EnableWebhookSync = false }), + Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false, AppSecret = appSecret }), + Options.Create(new StripeOptions()), + Options.Create(new BifrostHostOptions + { + PublicBaseUrl = "https://bifrost.example", + ExpectedHost = "bifrost.example" + }), + Options.Create(new ConceptVoteOptions + { + NetworkTagSecret = tagSecret ?? string.Empty, + IPv4PrefixLength = ipv4Length, + IPv6PrefixLength = ipv6Length, + AllowedOrigins = allowedOrigins ?? [], + CastPermits = castPermits, + CastWindow = castWindow ?? TimeSpan.FromMinutes(10), + MintPermits = mintPermits, + MintWindow = mintWindow ?? TimeSpan.FromHours(1), + MaxTrackedKeys = maxTrackedKeys + }), + Options.Create(new DeskOptions()), + Options.Create(new RegistrationOptions())).Validate(); + } + + private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + 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/MemberConsoleTests.cs b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs index a3addd0..e4e1804 100644 --- a/tests/Bifrost.Web.Tests/MemberConsoleTests.cs +++ b/tests/Bifrost.Web.Tests/MemberConsoleTests.cs @@ -1,4 +1,9 @@ using System.Net; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.ConceptVotes; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Tests.Support; @@ -143,4 +148,51 @@ public sealed class MemberConsoleTests : IClassFixture(); + var admin = await dbContext.UserAccounts + .Include(x => x.Membership) + .SingleAsync(x => x.NormalizedGitHubLogin == "TEST-ADMIN"); + var now = DateTimeOffset.UtcNow; + dbContext.ConceptSubjects.Add(new ConceptSubject + { + Collection = "aetheria-concept-mill", + Key = "ship-a", + Title = "ship-a", + RegisteredAtUtc = now + }); + await dbContext.SaveChangesAsync(); + + var service = scope.ServiceProvider.GetRequiredService(); + var options = scope.ServiceProvider.GetRequiredService>().Value; + var client = System.Net.IPAddress.Parse("203.0.113.7"); + tag = NetworkPrefix.Tag(client, options.IPv4PrefixLength, options.IPv6PrefixLength, options.NetworkTagSecret); + var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "member-view-device")!; + await service.CastAsync("aetheria-concept-mill", "ship-a", voter, ConceptStance.Up, client, default); + + var removed = await service.PurgeAsync( + new CurrentBifrostActor(true, admin), tag, now.AddHours(-1), now.AddHours(1), default); + Assert.Equal(1, removed); + } + + using var memberClient = _factory.CreateClient(); + using var response = await memberClient.GetAsync("/App"); + var html = await response.Content.ReadAsStringAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("Purge", html); + Assert.Contains("count", html); + Assert.DoesNotContain(tag, html); + } } 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 new file mode 100644 index 0000000..6d902cb --- /dev/null +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -0,0 +1,64 @@ +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Bifrost.Web.Tests; + +public sealed class MigrationDiscoveryTests +{ + private static readonly string[] ExpectedMigrations = + [ + "20260422084653_InitialAlphaFoundation", + "20260608210000_AddHeimdallUserIdentity", + "20260609143000_AddPatronSupportDerivation", + "20260609160000_AddExternalPatronProviderIntake", + "20260617010537_AddBridgeActionLedger", + "20260617022305_AddAgentDispatchRuns", + "20260617033635_AddAgentTransportReceipts", + "20260617041417_AddGovernanceActivityReceipts", + "20260622142000_AddBridgeActionIdentityProvenance", + "20260622165000_AddBifrostNativeIdentity", + "20260622202000_AddBridgeActionTargetSurfaceName", + "20260705190000_AddPatronSupportProjectAttribution", + "20261010043937_AddConceptVotes", + "20261022163159_AddOpenIddict", + "20261109200000_AddSealedBallotMotions", + "20261109210000_AddMindKeys", + "20261111090000_AddConsentReceipts", + "20261111100000_ConsentReceiptsOneLivePerVersion", + "20261111110000_AddMembershipSuspensionReason", + "20261111120000_ClearLegacyConsentReceipts", + ]; + + // Database.GetMigrations() reads assembly metadata only; no connection is opened. + private static List Discovered() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=unused") + .Options; + using var db = new BifrostDbContext(options); + return db.Database.GetMigrations().ToList(); + } + + [Fact] + public void Every_migration_type_is_discovered() + { + var discovered = Discovered(); + var declared = typeof(BifrostDbContext).Assembly.GetTypes() + .Where(t => !t.IsAbstract && typeof(Migration).IsAssignableFrom(t)) + .ToList(); + + Assert.NotEmpty(declared); + Assert.Equal(declared.Count, discovered.Count); + foreach (var type in declared) + { + Assert.Contains(discovered, id => id.EndsWith("_" + type.Name, StringComparison.Ordinal)); + } + } + + [Fact] + public void All_migrations_are_listed_in_order() + { + Assert.Equal(ExpectedMigrations, Discovered()); + } +} diff --git a/tests/Bifrost.Web.Tests/MindIntakeTests.cs b/tests/Bifrost.Web.Tests/MindIntakeTests.cs new file mode 100644 index 0000000..776bbac --- /dev/null +++ b/tests/Bifrost.Web.Tests/MindIntakeTests.cs @@ -0,0 +1,413 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; + +namespace Bifrost.Web.Tests; + +public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassFixture +{ + private const string Salt = "00112233445566778899aabbccddeeff"; + private const string Reason = "Distinctive-mind-reason-xyzzy."; + private static int _next = 5000; + + private sealed record Key(Ed25519PrivateKeyParameters Private) + { + public string Public => Convert.ToBase64String(Private.GeneratePublicKey().GetEncoded()); + public static Key New() => new(new Ed25519PrivateKeyParameters(new SecureRandom())); + } + + private sealed class World + { + public required string Persona { get; init; } + public required Guid Account { get; init; } + public required Guid RevealMotion { get; init; } + public required Guid CommitMotion { get; init; } + public required Key Key { get; init; } + public required Guid KeyId { get; init; } + public required HttpClient Moderator { get; init; } + public required string MemberGitHubId { get; init; } + } + + private async Task WorldAsync(bool bindRevealToPersona = true) + { + using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); } + var n = Interlocked.Add(ref _next, 10); + var persona = $"mind{n}"; + var now = DateTimeOffset.UtcNow; + Guid account, reveal, commit; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + UserAccount Make(long gh, string name) + { + var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name }; + u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now }; + u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = MembershipStatus.Active, CreatedAtUtc = now }; + Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed"); + db.UserAccounts.Add(u); + return u; + } + + var mind = Make(n + 1, persona); + Make(n + 2, $"member{n}"); + Motion M(DateTimeOffset commitCloses, DateTimeOffset closes) + { + var m = new Motion + { + CreatedByUserAccountId = mind.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", + Candidates = "Ana\nBo", OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, ClosesAtUtc = closes, CreatedAtUtc = now + }; + db.Motions.Add(m); + return m; + } + + var r = M(now.AddHours(-1), now.AddHours(1)); + var c = M(now.AddHours(1), now.AddHours(2)); + db.SealedBallots.Add(new SealedBallot + { + MotionId = r.Id, UserAccountId = mind.Id, CommittedAtUtc = now.AddHours(-2), + Commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, r.Id, 1, "Bo", Reason, Salt, bindRevealToPersona ? persona : ""), mind.Id) + }); + await db.SaveChangesAsync(); + (account, reveal, commit) = (mind.Id, r.Id, c.Id); + } + + var moderator = factory.CreateClient(); + var key = Key.New(); + var registered = await moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona, userAccountId = account, publicKey = key.Public }); + Assert.True(registered.StatusCode == HttpStatusCode.OK, await registered.Content.ReadAsStringAsync()); + var keyId = JsonDocument.Parse(await registered.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid(); + return new World { Persona = persona, Account = account, RevealMotion = reveal, CommitMotion = commit, Key = key, KeyId = keyId, Moderator = moderator, MemberGitHubId = (n + 2).ToString() }; + } + + private static MindEnvelope Sign(Key key, string persona, string verb, Guid motion, string? nonce = null, string? commitment = null, + string? candidate = null, string? reason = null, string? salt = null, long? issuedAt = null) + { + var e = new MindEnvelope(persona, verb, motion, nonce ?? Guid.NewGuid().ToString("N"), issuedAt ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(), commitment, 1, candidate, reason, salt, ""); + var signer = new Ed25519Signer(); + signer.Init(true, key.Private); + var bytes = System.Text.Encoding.UTF8.GetBytes(MindIntakeService.SigningText(e)); + signer.BlockUpdate(bytes, 0, bytes.Length); + return e with { Signature = Convert.ToBase64String(signer.GenerateSignature()) }; + } + + private HttpClient Anonymous() + { + var c = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + c.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); // the intake needs no session; this proves it + return c; + } + + private static Task Post(HttpClient c, Guid route, MindEnvelope e) => c.PostAsJsonAsync($"/api/sealed-motions/{route}/mind", e); + + private static async Task Refused(HttpResponseMessage r, HttpStatusCode expected) + { + var body = await r.Content.ReadAsStringAsync(); + Assert.True(r.StatusCode == expected, $"{r.StatusCode}: {body}"); + Assert.DoesNotContain(Reason, body); + Assert.DoesNotContain(Salt, body); + Assert.DoesNotContain("Bo", body.Replace("Bonce", "")); + } + + [Fact] + public async Task A_mind_commits_and_reveals_by_signature_alone_and_the_ballot_stays_sealed() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, Salt, w.Persona), w.Account); + var commit = await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment)); + Assert.True(commit.StatusCode == HttpStatusCode.OK, await commit.Content.ReadAsStringAsync()); + + var reveal = await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)); + Assert.True(reveal.StatusCode == HttpStatusCode.OK, await reveal.Content.ReadAsStringAsync()); + Assert.DoesNotContain(Reason, await reveal.Content.ReadAsStringAsync()); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.registered"); + var member = factory.CreateClient(); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member"); + var state = await (await member.GetAsync($"/api/sealed-motions/{w.RevealMotion}")).Content.ReadAsStringAsync(); + Assert.DoesNotContain(Reason, state); + Assert.DoesNotContain(Salt, state); + } + + [Fact] + public async Task Every_refusal_is_typed_and_leaks_no_ballot() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var other = Key.New(); + MindEnvelope Reveal(Key k, string persona = "", string? nonce = null, string? reason = null) => + Sign(k, persona == "" ? w.Persona : persona, "reveal", w.RevealMotion, nonce, candidate: "Bo", reason: reason ?? Reason, salt: Salt); + + await Refused(await Post(anon, w.RevealMotion, Reveal(other)), HttpStatusCode.Unauthorized); // key not registered + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Reason = "tampered" }), HttpStatusCode.Unauthorized); // signature covers every field + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Signature = "AAAA" }), HttpStatusCode.Unauthorized); + await Refused(await Post(anon, w.CommitMotion, Reveal(w.Key)), HttpStatusCode.BadRequest); // signed for another motion than the route + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, nonce: "short")), HttpStatusCode.BadRequest); // malformed nonce + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Verb = "tally" }), HttpStatusCode.BadRequest); + await Refused(await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "reveal", w.CommitMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Conflict); // wrong phase + + // A second persona with its own key cannot speak as the first: persona id must match the signing key. + var rival = await WorldAsync(); + await Refused(await Post(anon, w.RevealMotion, Sign(rival.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized); + + // Replay: the same signed envelope is accepted once, then refused. + var once = Reveal(w.Key); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, once)).StatusCode); + await Refused(await Post(anon, w.RevealMotion, once), HttpStatusCode.Conflict); + // A bad signature does not spend the nonce. + var fresh = Reveal(w.Key); + await Refused(await Post(anon, w.RevealMotion, fresh with { Reason = "x" }), HttpStatusCode.Unauthorized); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, fresh)).StatusCode); + } + + [Fact] + public async Task A_commitment_not_bound_to_the_persona_cannot_be_revealed_through_the_intake() + { + var w = await WorldAsync(bindRevealToPersona: false); + await Refused(await Post(Anonymous(), w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.BadRequest); + } + + [Fact] + public async Task A_revoked_key_is_refused_and_a_commitment_made_before_revocation_stays() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var commitment = new string('c', 64); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment))).StatusCode); + + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.revoked"); + + // Rotation: a new key registered for the same persona works. + var next = Key.New(); + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = next.Public })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(next, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode); + } + + [Fact] + public async Task Only_a_moderator_manages_keys_and_a_persona_stays_bound_to_one_account() + { + var w = await WorldAsync(); + var member = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member"); + var body = new { persona = w.Persona, userAccountId = w.Account, publicKey = Key.New().Public }; + Assert.Equal(HttpStatusCode.Forbidden, (await member.PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await member.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await member.GetAsync("/api/sealed-motions/mind-keys")).StatusCode); + Assert.NotEqual(HttpStatusCode.OK, (await Anonymous().PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode); + + var elsewhere = new { persona = w.Persona, userAccountId = Guid.NewGuid(), publicKey = Key.New().Public }; + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", elsewhere)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = "Bad Name", userAccountId = w.Account, publicKey = Key.New().Public })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = "AAAA" })).StatusCode); + } + + [Fact] + public async Task The_signing_helper_and_the_server_agree_on_the_envelope_and_the_commitment() + { + var w = await WorldAsync(); + var root = AppContext.BaseDirectory; + while (!Directory.Exists(Path.Combine(root, "tools"))) root = Path.GetDirectoryName(root)!; + var keyFile = Path.Combine(Path.GetTempPath(), $"sealed-{Guid.NewGuid():N}.key"); + var seed = w.Key.Private.GetEncoded(); + await File.WriteAllBytesAsync(keyFile, seed); + File.SetUnixFileMode(keyFile, UnixFileMode.UserRead); + try + { + string Run(params string[] a) + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true }; + psi.ArgumentList.Add(Path.Combine(root, "tools", "sealed-motion-sign.mjs")); + foreach (var x in a) psi.ArgumentList.Add(x); + using var p = Process.Start(psi)!; + var o = p.StandardOutput.ReadToEnd(); + p.WaitForExit(); + Assert.True(p.ExitCode == 0, p.StandardError.ReadToEnd()); + return o.Trim(); + } + + Assert.Equal(w.Key.Public, Run("pubkey", "--key", keyFile)); + var committed = JsonDocument.Parse(Run("commit", "--key", keyFile, "--persona", w.Persona, "--motion", w.CommitMotion.ToString(), + "--voter", w.Account.ToString(), "--candidate", "Ana", "--reason", Reason)); + var post = await Anonymous().PostAsync($"/api/sealed-motions/{w.CommitMotion}/mind", + new StringContent(committed.RootElement.GetProperty("envelope").GetRawText(), System.Text.Encoding.UTF8, "application/json")); + Assert.True(post.StatusCode == HttpStatusCode.OK, await post.Content.ReadAsStringAsync()); + + var ballot = committed.RootElement.GetProperty("ballot"); + var expected = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, ballot.GetProperty("salt").GetString()!, w.Persona), w.Account); + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(expected, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + } + finally { File.Delete(keyFile); } + } + + [Fact] + public async Task Every_refusal_before_a_verified_signature_costs_one_signature_check_and_reveals_nothing_about_state() + { + var w = await WorldAsync(); + var anon = Anonymous(); + long Checks() => Interlocked.Read(ref MindIntakeService.SignatureChecks); + MindEnvelope Reveal(Key k, string persona) => Sign(k, persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt); + + var before = Checks(); + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona + Assert.True(Checks() > before, "unknown persona skipped the verification"); + + before = Checks(); + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, w.Persona) with { Signature = "not-base64!" }), HttpStatusCode.Unauthorized); // malformed signature + Assert.True(Checks() > before, "malformed signature skipped the verification"); + + // Route/motion mismatch and a stale clock are answered 400 only to a caller whose signature verified; an unsigned one gets the 401. + var wrongRoute = Reveal(w.Key, w.Persona); + await Refused(await Post(anon, w.CommitMotion, wrongRoute with { Signature = Convert.ToBase64String(new byte[64]) }), HttpStatusCode.Unauthorized); + await Refused(await Post(anon, w.CommitMotion, wrongRoute), HttpStatusCode.BadRequest); + + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + before = Checks(); + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, w.Persona)), HttpStatusCode.Unauthorized); // revoked + Assert.True(Checks() > before, "revoked key skipped the verification"); + } + + [Fact] + public async Task A_stale_envelope_is_refused_and_old_nonces_are_pruned() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var old = DateTimeOffset.UtcNow.AddHours(-1); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.MindNonces.Add(new MindNonce { PersonaId = w.Persona, Nonce = "old-nonce-old-nonce-1234", SeenAtUtc = old }); + await db.SaveChangesAsync(); + } + + await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt, + issuedAt: DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds())), HttpStatusCode.BadRequest); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode); + + using var check = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.DoesNotContain(check.MindNonces, x => x.Nonce == "old-nonce-old-nonce-1234"); + Assert.Single(check.MindNonces.Where(x => x.PersonaId == w.Persona)); + } + + [Fact] + public async Task The_endpoint_caps_the_body_and_rate_limits_per_source() + { + var w = await WorldAsync(); + var big = new StringContent(new string('x', 10_000), System.Text.Encoding.UTF8, "application/json"); + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await Anonymous().PostAsync($"/api/sealed-motions/{w.RevealMotion}/mind", big)).StatusCode); + + using var limited = factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, c) => c.AddInMemoryCollection(new Dictionary { ["MindIntake:Permits"] = "3" }))); + var client = limited.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); + var codes = new List(); + for (var i = 0; i < 5; i++) + { + codes.Add((await Post(client, w.RevealMotion, Sign(Key.New(), w.Persona, "reveal", w.RevealMotion))).StatusCode); + } + + Assert.Equal([HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests], codes); + } + + [Fact] + public async Task One_account_cannot_carry_two_personas_and_the_bindings_are_unique_in_the_model() + { + var w = await WorldAsync(); + var second = await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona + "b", userAccountId = w.Account, publicKey = Key.New().Public }); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + var type = db.Model.FindEntityType(typeof(MindPersona))!; + Assert.Contains(type.GetIndexes(), i => i.IsUnique && i.Properties.Single().Name == nameof(MindPersona.PersonaId)); + Assert.Contains(type.GetIndexes(), i => i.IsUnique && i.Properties.Single().Name == nameof(MindPersona.UserAccountId)); + Assert.Contains(db.Model.FindEntityType(typeof(MindNonce))!.GetIndexes(), i => i.IsUnique && i.Properties.Count == 2); + } + + private sealed class ThrowOnNonce : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync(DbContextEventData e, InterceptionResult r, CancellationToken ct = default) => + e.Context!.ChangeTracker.Entries().Any(x => x.State == EntityState.Added) + ? throw new DbUpdateException("disk fault") + : ValueTask.FromResult(r); + } + + [Fact] + public async Task A_write_failure_that_is_not_a_nonce_collision_is_not_reported_as_a_replay() + { + var clock = TimeProvider.System; + await using var db = new BifrostDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"intake-fault-{Guid.NewGuid():N}").AddInterceptors(new ThrowOnNonce()).Options); + var key = Key.New(); + var user = new UserAccount { DisplayName = "m", GitHubLogin = "m", BifrostIdentity = "m", NormalizedBifrostIdentity = "M", NormalizedGitHubLogin = "M" }; + user.Membership = new Domain.Membership { UserAccountId = user.Id, Status = MembershipStatus.Active }; + db.UserAccounts.Add(user); + db.MindKeys.Add(new MindKey { PersonaId = "mindx", UserAccountId = user.Id, PublicKey = key.Public, RegisteredAtUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + var audit = new AuditTrailService(db, clock); + var service = new MindIntakeService(db, new SealedBallotService(db, audit, clock), audit, clock); + var motion = Guid.NewGuid(); + + await Assert.ThrowsAsync(() => service.HandleAsync(motion, Sign(key, "mindx", "commit", motion, commitment: new string('a', 64)), default)); + } + + private sealed class FailOnNonceDelete : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync(DbContextEventData e, InterceptionResult r, CancellationToken ct = default) => + e.Context!.ChangeTracker.Entries().Any(x => x.State == EntityState.Deleted) + ? throw new DbUpdateConcurrencyException("the other request deleted these rows first") + : ValueTask.FromResult(r); + } + + [Fact] + public async Task A_prune_that_loses_a_race_never_fails_a_valid_request() + { + var clock = TimeProvider.System; + await using var db = new BifrostDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"intake-prune-{Guid.NewGuid():N}").AddInterceptors(new FailOnNonceDelete()).Options); + var key = Key.New(); + var user = new UserAccount { DisplayName = "m", GitHubLogin = "m", BifrostIdentity = "m", NormalizedBifrostIdentity = "M", NormalizedGitHubLogin = "M" }; + user.Membership = new Domain.Membership { UserAccountId = user.Id, Status = MembershipStatus.Active }; + var now = DateTimeOffset.UtcNow; + var motion = new Motion + { + CreatedByUserAccountId = user.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", Candidates = "Ana\nBo", + OpensAtUtc = now, CommitClosesAtUtc = now.AddHours(1), ClosesAtUtc = now.AddHours(2), CreatedAtUtc = now + }; + db.UserAccounts.Add(user); + db.Motions.Add(motion); + db.MindKeys.Add(new MindKey { PersonaId = "mindx", UserAccountId = user.Id, PublicKey = key.Public, RegisteredAtUtc = now }); + db.MindNonces.Add(new MindNonce { PersonaId = "mindx", Nonce = "expired-nonce-expired-1", SeenAtUtc = now.AddHours(-2) }); + await db.SaveChangesAsync(); + var audit = new AuditTrailService(db, clock); + var service = new MindIntakeService(db, new SealedBallotService(db, audit, clock), audit, clock); + + var receipt = await service.HandleAsync(motion.Id, Sign(key, "mindx", "commit", motion.Id, commitment: new string('a', 64)), default); + + Assert.Equal(new string('a', 64), receipt.Commitment); + Assert.Equal(1, await db.MindNonces.CountAsync(x => x.Nonce != "expired-nonce-expired-1")); // the nonce was still spent + } +} diff --git a/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs new file mode 100644 index 0000000..79af76b --- /dev/null +++ b/tests/Bifrost.Web.Tests/NetworkPrefixTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using Bifrost.Web.Features.ConceptVotes; + +namespace Bifrost.Web.Tests; + +public sealed class NetworkPrefixTests +{ + private const string Secret = "prefix-secret"; + + private static string Tag(string address, int v4 = 24, int v6 = 56, string secret = Secret) => + NetworkPrefix.Tag(IPAddress.Parse(address), v4, v6, secret); + + [Fact] + public void Two_addresses_inside_one_prefix_share_a_tag_and_other_prefixes_do_not() + { + Assert.Equal(Tag("203.0.113.7"), Tag("203.0.113.200")); + Assert.NotEqual(Tag("203.0.113.7"), Tag("203.0.114.7")); + Assert.Equal(Tag("2001:db8:1:ff::1"), Tag("2001:db8:1:ff:aaaa::9")); + Assert.NotEqual(Tag("2001:db8:1:ff::1"), Tag("2001:db8:2:ff::1")); + Assert.Equal("203.0.113.0/24", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 24, 56)); + Assert.Equal("2001:db8:1:ff00::/56", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ffee::1"), 24, 56)); + } + + [Fact] + public void Prefix_length_is_the_configured_one() + { + Assert.NotEqual(Tag("203.0.113.7", v4: 32), Tag("203.0.113.8", v4: 32)); + Assert.Equal(Tag("2001:db8:1:ff::1", v6: 48), Tag("2001:db8:1:aa::1", v6: 48)); + } + + [Fact] + public void IPv4_mapped_IPv6_equals_the_IPv4_address() + { + Assert.Equal(Tag("203.0.113.7"), Tag("::ffff:203.0.113.7")); + } + + [Fact] + public void A_different_secret_gives_a_different_tag_and_the_tag_is_lowercase_hex() + { + var tag = Tag("203.0.113.7"); + + Assert.NotEqual(tag, Tag("203.0.113.7", secret: "other-secret")); + Assert.Matches("^[0-9a-f]{64}$", tag); + } + + [Fact] + public void A_length_outside_the_family_is_refused_without_echoing_the_address() + { + var ex = Assert.Throws( + () => NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 33, 56)); + + Assert.DoesNotContain("203.0.113.7", ex.Message); + Assert.Contains("Prefix length", ex.Message); + } + + [Fact] + public void Masking_is_exact_inside_a_byte_and_a_zero_length_is_the_whole_family() + { + Assert.Equal("203.0.112.0/20", NetworkPrefix.Of(IPAddress.Parse("203.0.127.200"), 20, 56)); + Assert.Equal("2001:db8:1:f0::/60", NetworkPrefix.Of(IPAddress.Parse("2001:db8:1:ff::1"), 24, 60)); + Assert.Equal("0.0.0.0/0", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 0, 56)); + Assert.Equal("203.0.113.7/32", NetworkPrefix.Of(IPAddress.Parse("203.0.113.7"), 32, 56)); + } +} 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 new file mode 100644 index 0000000..2c3b9a5 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcIssuerTests.cs @@ -0,0 +1,1235 @@ +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 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; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Tests; + +public sealed class OidcIssuerTests : IClassFixture +{ + private static readonly Uri Origin = new("https://localhost"); + private OidcWebApplicationFactory _factory; + + public OidcIssuerTests(OidcWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task Discovery_advertises_ES256_and_the_code_flow_only() + { + using var client = NewClient(); + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + + Assert.Equal(Origin.AbsoluteUri, discovery.GetProperty("issuer").GetString()); + Assert.Equal("/.well-known/jwks", new Uri(discovery.GetProperty("jwks_uri").GetString()!).AbsolutePath); + Assert.Equal(["ES256"], Strings(discovery, "id_token_signing_alg_values_supported")); + Assert.Equal(["code"], Strings(discovery, "response_types_supported")); + Assert.Equal(["authorization_code"], Strings(discovery, "grant_types_supported")); + Assert.Contains("S256", Strings(discovery, "code_challenge_methods_supported")); + Assert.Equal(["client_secret_basic"], Strings(discovery, "token_endpoint_auth_methods_supported")); + Assert.Equal(["openid", "profile"], Strings(discovery, "scopes_supported").Order()); + } + + [Fact] + public async Task Jwks_holds_exactly_the_configured_P256_key() + { + using var client = NewClient(); + var jwks = await GetJwksAsync(client); + + var key = Assert.Single(jwks.GetProperty("keys").EnumerateArray()); + Assert.Equal("EC", key.GetProperty("kty").GetString()); + Assert.Equal("P-256", key.GetProperty("crv").GetString()); + Assert.Equal("ES256", key.GetProperty("alg").GetString()); + + using var ec = ECDsa.Create(); + ec.ImportFromPem(_factory.Kit.SigningPublicKeyPem); + var expected = ec.ExportParameters(false); + Assert.Equal(Base64UrlEncoder.Encode(expected.Q.X), key.GetProperty("x").GetString()); + Assert.Equal(Base64UrlEncoder.Encode(expected.Q.Y), key.GetProperty("y").GetString()); + Assert.False(key.TryGetProperty("d", out _)); + } + + [Fact] + public async Task An_active_member_completes_code_and_pkce_and_gets_the_right_claims() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Ada Lovelace"); + var accountId = _factory.AccountId(identity); + await SetLinksAsync(identity, "heimdall-canary-7", "github-canary-7"); + + 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")); + Assert.Equal("Ada Lovelace", verified.Claim("name")); + Assert.Equal(["ES256"], [verified.Alg]); + + // Nothing about the authenticator links, e-mail or membership leaves Bifrost. + Assert.DoesNotContain("heimdall-canary-7", verified.PayloadJson); + Assert.DoesNotContain("github-canary-7", verified.PayloadJson); + Assert.DoesNotContain("email", verified.PayloadJson, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("urn:bifrost", verified.PayloadJson); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var userinfoResponse = await NewClient().SendAsync(userinfo); + Assert.Equal(HttpStatusCode.OK, userinfoResponse.StatusCode); + var body = await userinfoResponse.Content.ReadAsStringAsync(); + var info = JsonDocument.Parse(body).RootElement; + Assert.Equal(accountId.ToString("D"), info.GetProperty("sub").GetString()); + Assert.Equal(identity, info.GetProperty("preferred_username").GetString()); + Assert.Equal("Ada Lovelace", info.GetProperty("name").GetString()); + Assert.DoesNotContain("canary", body); + } + + [Fact] + public async Task Sub_and_preferred_username_are_stable_across_display_name_changes() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "First Name"); + + var first = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + await _factory.SetDisplayNameAsync(identity, "Second Name"); + var second = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + + Assert.Equal(first.Claim("sub"), second.Claim("sub")); + Assert.Equal(first.Claim("preferred_username"), second.Claim("preferred_username")); + Assert.Equal("First Name", first.Claim("name")); + Assert.Equal("Second Name", second.Claim("name")); + } + + [Fact] + public async Task A_suspended_account_is_refused_with_access_denied_and_no_code() + { + 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.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")); + } + + [Fact] + public async Task An_active_account_with_no_bifrost_identity_is_refused_never_given_an_empty_username() + { + using var factory = new OidcNoIdentityFactory(); + using var client = factory.CreateClient(ClientOptions()); + await RegisterAsync(client, $"noid-{Guid.NewGuid():N}"); + 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.NoIdentityDescription, redirect["error_description"]); + 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)] + [InlineData(OidcTestKit.ClientId, OidcTestKit.RedirectUri, false)] + public async Task Unknown_client_wrong_redirect_uri_and_missing_pkce_are_refused_before_any_sign_in_redirect( + string clientId, string redirectUri, bool pkce) + { + using var client = NewClient(); + var request = NewRequest(clientId, redirectUri, pkce); + + using var response = await client.GetAsync(request.Path); + + var location = response.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("/auth/sign-in", location); + Assert.DoesNotContain("request_uri=", location); + Assert.DoesNotContain("code=", location); + Assert.NotEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task A_mas_sized_authorize_request_survives_sign_in_and_its_handle_is_single_use() + { + var request = NewRequest(extra: "&ui_locales=en&prompt=login&claims_locales=en&max_age=3600"); + Assert.True(request.Path.Length > LocalReturnPath.MaxLength, "the request must be longer than a sign-in return path"); + + using var client = NewClient(); + var signIn = await DriveAsync(client, request); + Assert.Equal(HttpStatusCode.Redirect, signIn.StatusCode); + Assert.Equal("/auth/sign-in", signIn.Headers.Location!.IsAbsoluteUri + ? signIn.Headers.Location.AbsolutePath + : signIn.Headers.Location.OriginalString.Split('?')[0]); + var resume = ReturnUrl(signIn); + Assert.True(resume.Length <= LocalReturnPath.MaxLength, "the resume path must fit the sign-in return path"); + Assert.Equal(resume, LocalReturnPath.Parse(resume).Value); + + var identity = await RegisterAsync(client, $"resume-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, MembershipStatus.Active); + + using var resumed = await client.GetAsync(resume); + var redirect = AssertRedirectToClient(resumed, request.State); + Assert.True(redirect.ContainsKey("code"), "the resumed request issues a code"); + + var tokens = await ExchangeAsync(client, request, redirect["code"]!); + var verified = await ValidateIdTokenAsync(tokens.IdToken, request.Nonce); + Assert.Equal(request.Nonce, verified.Claim("nonce")); + + // The handle was spent by the first resume. + using var replay = await client.GetAsync(resume); + var replayRedirect = replay.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("code=", replayRedirect); + Assert.NotEqual(HttpStatusCode.OK, replay.StatusCode); + } + + [Fact] + public async Task A_parked_authorize_request_expires() + { + // Own host: moving the clock must not skew the id_token checks of the other tests. + using var own = new OidcWebApplicationFactory(); + await new OidcIssuerTests(own).ParkedRequestIsRefusedAfterItsLifetimeAsync(); + } + + private async Task ParkedRequestIsRefusedAfterItsLifetimeAsync() + { + // Fixed probe times, not the constant: a lifetime of a day, or of a minute, must show. + var early = NewRequest(); + var late = NewRequest(); + using var client = NewClient(); + var earlyResume = ReturnUrl(await DriveAsync(client, early)); + var lateResume = ReturnUrl(await DriveAsync(client, late)); + var identity = await RegisterAsync(client, $"late-{Guid.NewGuid():N}"); + await _factory.SetMembershipAsync(identity, MembershipStatus.Active); + + _factory.Clock.Advance(TimeSpan.FromMinutes(5)); + using var withinLifetime = await client.GetAsync(earlyResume); + Assert.True(AssertRedirectToClient(withinLifetime, early.State).ContainsKey("code"), "five minutes is within the lifetime"); + + _factory.Clock.Advance(TimeSpan.FromMinutes(25)); + using var expired = await client.GetAsync(lateResume); + var location = expired.Headers.Location?.OriginalString ?? string.Empty; + Assert.DoesNotContain("code=", location); + Assert.NotEqual(HttpStatusCode.OK, expired.StatusCode); + } + + [Fact] + public async Task The_token_endpoint_accepts_client_secret_basic_only() + { + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Basic Only"); + var request = NewRequest(); + var code = await CodeAsync(client, request); + + var wrongSecret = await TokenAsync(client, request, code, secret: "wrong-secret"); + Assert.NotEqual(HttpStatusCode.OK, wrongSecret.StatusCode); + + var asPost = await TokenAsync(client, request, code, secretInBody: true); + Assert.NotEqual(HttpStatusCode.OK, asPost.StatusCode); + } + + [Fact] + public async Task Identity_is_minted_once() + { + using var scope = _factory.Services.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + var account = new UserAccount { BifrostIdentity = "Original", NormalizedBifrostIdentity = "original" }; + + await service.EnsureIdentityAsync(account, "someone-else", CancellationToken.None); + + Assert.Equal("original", account.NormalizedBifrostIdentity); + Assert.Equal("Original", account.BifrostIdentity); + } + + [Fact] + public async Task The_openid_scope_alone_releases_the_subject_and_nothing_else() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, "Scoped Down"); + var request = NewRequest(scope: "openid"); + var code = await CodeAsync(client, request); + + var tokens = await ExchangeAsync(client, request, code); + + var verified = await ValidateIdTokenAsync(tokens.IdToken, request.Nonce); + Assert.Equal(_factory.AccountId(identity).ToString("D"), verified.Claim("sub")); + Assert.Null(verified.Claim("preferred_username")); + Assert.Null(verified.Claim("name")); + + using var userinfo = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + userinfo.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken); + using var response = await NewClient().SendAsync(userinfo); + var info = (await response.Content.ReadFromJsonAsync()).EnumerateObject().Select(x => x.Name).ToArray(); + Assert.Equal(["sub"], info); + } + + [Fact] + public async Task A_blank_display_name_falls_back_to_the_bifrost_identity() + { + var (client, identity) = await SignedInAsync(MembershipStatus.Active, " "); + + var verified = await ValidateIdTokenAsync((await CompleteFlowAsync(client)).IdToken, null); + + Assert.Equal(identity, verified.Claim("name")); + } + + [Fact] + public async Task Userinfo_without_a_valid_token_is_refused() + { + using var client = NewClient(); + using var none = await client.GetAsync("/connect/userinfo"); + Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode); + + using var forged = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + forged.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-a-token"); + using var forgedResponse = await client.SendAsync(forged); + Assert.Equal(HttpStatusCode.Unauthorized, forgedResponse.StatusCode); + } + + [Fact] + public async Task Startup_makes_the_one_client_match_configuration_again() + { + // Own host: the drift below leaves the application manager's cache stale for every other test. + using var own = new OidcWebApplicationFactory(); + await new OidcIssuerTests(own).SeederRestoresTheClientAsync(); + } + + private async Task SeederRestoresTheClientAsync() + { + using var warm = NewClient(); + await warm.GetAsync("/healthz"); + using var scope = _factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var application = await manager.FindByClientIdAsync(OidcTestKit.ClientId); + Assert.NotNull(application); + + // Someone widened the client in the database; the next start puts configuration back. + var drifted = new OpenIddict.Abstractions.OpenIddictApplicationDescriptor + { + ClientId = OidcTestKit.ClientId, + ClientType = OpenIddict.Abstractions.OpenIddictConstants.ClientTypes.Confidential, + ClientSecret = "drifted-secret", + RedirectUris = { new Uri("https://evil.example.test/callback") } + }; + await manager.UpdateAsync(application, drifted); + var seeders = _factory.Services.GetServices().OfType().ToList(); + var seeder = Assert.Single(seeders); + await seeder.StartAsync(CancellationToken.None); + + // Read the stored row, not through the manager: the manager's cache still holds the drifted instance. + using var fresh = _factory.Services.CreateScope(); + var rows = fresh.ServiceProvider.GetRequiredService() + .Set() + .AsNoTracking().ToList(); + var stored = Assert.Single(rows); + Assert.Equal([OidcTestKit.RedirectUri], (await manager.GetRedirectUrisAsync(stored)).ToArray()); + Assert.True(await manager.ValidateClientSecretAsync(stored, _factory.Kit.ClientSecret)); + Assert.False(await manager.ValidateClientSecretAsync(stored, "drifted-secret")); + } + + [Theory] + [InlineData(MembershipStatus.Suspended)] + [InlineData(null)] + 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(); + var code = await CodeAsync(client, request); + + await LeaveActiveAsync(identity, status); + + using var response = await TokenAsync(client, request, code); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("invalid_grant", body); + Assert.DoesNotContain("id_token", body); + Assert.DoesNotContain("access_token", body); + } + + [Theory] + [InlineData(MembershipStatus.Suspended)] + [InlineData(null)] + 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); + + using (var before = await UserInfoAsync(tokens.AccessToken)) + { + Assert.Equal(HttpStatusCode.OK, before.StatusCode); + } + + await LeaveActiveAsync(identity, status); + + using var after = await UserInfoAsync(tokens.AccessToken); + Assert.Equal(HttpStatusCode.Forbidden, after.StatusCode); + Assert.DoesNotContain(identity, await after.Content.ReadAsStringAsync()); + } + + private Task UserInfoAsync(string accessToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/connect/userinfo"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + return NewClient().SendAsync(request); + } + + /// A null status deletes the account instead. + private Task LeaveActiveAsync(string identity, MembershipStatus? status) => + status is { } value ? _factory.SetMembershipAsync(identity, value) : _factory.DeleteAccountAsync(identity); + + [Fact] + public async Task Startup_removes_every_client_but_the_configured_one_with_its_tokens_and_authorizations() + { + // Own host: the stale clients are rows the other tests must not see. + using var own = new OidcCascadeFactory(); + await new OidcIssuerTests(own).StaleClientsAreRemovedAsync(); + } + + private async Task StaleClientsAreRemovedAsync() + { + // Two stale clients, one of them differing from the configured id only in case. + var stale = new[] + { + (Id: "old-mas", Redirect: "https://old.example.test/callback"), + (Id: OidcTestKit.ClientId.ToUpperInvariant(), Redirect: "https://shouting.example.test/callback") + }; + var (client, _) = await SignedInAsync(MembershipStatus.Active, "Member"); + using (var scope = _factory.Services.CreateScope()) + { + var manager = scope.ServiceProvider.GetRequiredService(); + foreach (var (id, redirect) in stale) + { + await manager.CreateAsync(new OpenIddict.Abstractions.OpenIddictApplicationDescriptor + { + ClientId = id, + ClientSecret = "stale-secret", + ClientType = OpenIddict.Abstractions.OpenIddictConstants.ClientTypes.Confidential, + RedirectUris = { new Uri(redirect) }, + Permissions = + { + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Authorization, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Token, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.ResponseTypes.Code, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Scopes.Profile, + OpenIddict.Abstractions.OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddict.Abstractions.OpenIddictConstants.Scopes.OpenId + } + }); + } + } + + // The control: before seeding every client, stale or configured, really can obtain a code and so holds + // an authorization and a token. + foreach (var (id, redirect) in stale) + { + using var issued = await DriveAsync(client, NewRequest(id, redirect)); + Assert.Contains("code=", issued.Headers.Location?.OriginalString ?? string.Empty); + } + + _ = await CodeAsync(client, NewRequest()); + var staleCounts = Counts(stale.Select(x => x.Id)); + Assert.Equal(stale.Length, staleCounts.Count); + Assert.All(staleCounts, x => Assert.True(x.Tokens > 0 && x.Authorizations > 0, "the control must leave grants to remove")); + var configuredBefore = Counts([OidcTestKit.ClientId]).Single(); + Assert.True(configuredBefore.Tokens > 0 && configuredBefore.Authorizations > 0); + + var seeder = Assert.Single(_factory.Services.GetServices().OfType()); + await seeder.StartAsync(CancellationToken.None); + + foreach (var (id, redirect) in stale) + { + using var refused = await DriveAsync(client, NewRequest(id, redirect)); + Assert.DoesNotContain("code=", refused.Headers.Location?.OriginalString ?? string.Empty); + Assert.NotEqual(HttpStatusCode.OK, refused.StatusCode); + } + + using var fresh = _factory.Services.CreateScope(); + var db = fresh.ServiceProvider.GetRequiredService(); + var rows = db.Set().AsNoTracking().ToList(); + Assert.Equal([OidcTestKit.ClientId], rows.Select(x => x.ClientId).ToArray()); + + // Nothing issued to a removed client survives, and the configured client's grants are untouched. + var keptId = rows.Single().Id; + Assert.Empty(db.Set().AsNoTracking().Where(x => x.Application!.Id != keptId)); + Assert.Empty(db.Set().AsNoTracking().Where(x => x.Application!.Id != keptId)); + Assert.Equal(configuredBefore, Counts([OidcTestKit.ClientId]).Single()); + } + + private sealed record GrantCount(int Tokens, int Authorizations); + + private List Counts(IEnumerable clientIds) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var applications = db.Set().AsNoTracking().ToList(); + return clientIds + .Select(id => applications.Single(x => x.ClientId == id).Id) + .Select(applicationId => new GrantCount( + db.Set().Count(x => x.Application!.Id == applicationId), + db.Set().Count(x => x.Application!.Id == applicationId))) + .ToList(); + } + + [Fact] + public async Task The_issuer_is_the_configured_public_base_url_not_the_request_origin() + { + using var factory = new OidcForeignIssuerFactory(); + using var client = factory.CreateClient(ClientOptions()); + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + + 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() + { + AllowAutoRedirect = false, + HandleCookies = true, + BaseAddress = Origin + }; + + private HttpClient NewClient() => _factory.CreateClient(ClientOptions()); + + private static async Task RegisterAsync(HttpClient client, string identity, bool agree = true) + { + 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, ReceiptState receipt = ReceiptState.Current) + { + var client = NewClient(); + 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); + } + + private async Task SetLinksAsync(string identity, string heimdallAccountId, string gitHubLogin) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var account = db.UserAccounts.Single(x => x.NormalizedBifrostIdentity == identity); + account.HeimdallAccountId = heimdallAccountId; + account.GitHubLogin = gitHubLogin; + await db.SaveChangesAsync(); + } + + private sealed record AuthRequest(string Verifier, string State, string Nonce, string Path) + { + public string Challenge => Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(Verifier))); + } + + private static AuthRequest NewRequest( + string clientId = OidcTestKit.ClientId, + string redirectUri = OidcTestKit.RedirectUri, + bool pkce = true, + string extra = "", + string scope = "openid%20profile") + { + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(48)); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(24)); + var nonce = 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={scope}" + + $"&state={state}&nonce={nonce}" + + (pkce ? $"&code_challenge={challenge}&code_challenge_method=S256" : string.Empty) + + extra; + return new AuthRequest(verifier, state, nonce, 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 string ReturnUrl(HttpResponseMessage signInRedirect) + { + var location = signInRedirect.Headers.Location!; + var query = location.IsAbsoluteUri ? location.Query : location.OriginalString[location.OriginalString.IndexOf('?')..]; + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(query) + .Single(x => string.Equals(x.Key, "returnUrl", StringComparison.OrdinalIgnoreCase)).Value.ToString(); + } + + private static Dictionary AssertRedirectToClient(HttpResponseMessage response, string state) + { + Assert.True(response.StatusCode is HttpStatusCode.Redirect or HttpStatusCode.Found, $"expected a redirect, got {(int)response.StatusCode}"); + var location = response.Headers.Location!; + Assert.Equal(new Uri(OidcTestKit.RedirectUri).GetLeftPart(UriPartial.Path), location.GetLeftPart(UriPartial.Path)); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(location.Query); + Assert.Equal(state, query["state"].ToString()); + return query.ToDictionary(x => x.Key, x => (string?)x.Value.ToString()); + } + + private async Task CodeAsync(HttpClient signedIn, AuthRequest request) + { + var final = await DriveAsync(signedIn, request); + return AssertRedirectToClient(final, request.State)["code"]!; + } + + private sealed record Tokens(AuthRequest Request, string IdToken, string AccessToken); + + private async Task CompleteFlowAsync(HttpClient signedIn) + { + var request = NewRequest(); + var code = await CodeAsync(signedIn, request); + return await ExchangeAsync(signedIn, request, code); + } + + private async Task ExchangeAsync(HttpClient client, AuthRequest request, string code) + { + using var response = await TokenAsync(client, request, code); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.DoesNotContain("refresh_token", body.EnumerateObject().Select(x => x.Name)); + return new Tokens(request, body.GetProperty("id_token").GetString()!, body.GetProperty("access_token").GetString()!); + } + + private async Task TokenAsync( + HttpClient client, AuthRequest request, string code, string? secret = null, bool secretInBody = false) + { + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = OidcTestKit.RedirectUri, + ["code_verifier"] = request.Verifier + }; + var message = new HttpRequestMessage(HttpMethod.Post, "/connect/token"); + var effectiveSecret = secret ?? _factory.Kit.ClientSecret; + if (secretInBody) + { + form["client_id"] = OidcTestKit.ClientId; + form["client_secret"] = effectiveSecret; + } + else + { + message.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{OidcTestKit.ClientId}:{effectiveSecret}"))); + } + + message.Content = new FormUrlEncodedContent(form); + // The code is bound to the client; use a cookieless client so a session cannot influence the exchange. + using var bare = NewClient(); + return await bare.SendAsync(message); + } + + private sealed record Verified(string Alg, string PayloadJson, JsonElement Payload) + { + public string? Claim(string name) => Payload.TryGetProperty(name, out var value) ? value.GetString() : null; + } + + private async Task ValidateIdTokenAsync(string idToken, string? nonce) + { + using var client = NewClient(); + var jwks = new JsonWebKeySet((await GetJwksAsync(client)).GetRawText()); + var result = await new JsonWebTokenHandler().ValidateTokenAsync(idToken, new TokenValidationParameters + { + ValidIssuer = Origin.AbsoluteUri, + ValidAudience = OidcTestKit.ClientId, + IssuerSigningKeys = jwks.GetSigningKeys(), + ValidAlgorithms = ["ES256"] + }); + Assert.True(result.IsValid, result.Exception?.Message); + var jwt = (JsonWebToken)result.SecurityToken; + var payload = Encoding.UTF8.GetString(Base64UrlEncoder.DecodeBytes(jwt.EncodedPayload)); + if (nonce is not null) + { + Assert.Equal(nonce, JsonDocument.Parse(payload).RootElement.GetProperty("nonce").GetString()); + } + + return new Verified(jwt.Alg, payload, JsonDocument.Parse(payload).RootElement.Clone()); + } + + private static async Task GetJwksAsync(HttpClient client) + { + var discovery = await client.GetFromJsonAsync("/.well-known/openid-configuration"); + var jwksUri = new Uri(discovery.GetProperty("jwks_uri").GetString()!); + return await client.GetFromJsonAsync(jwksUri.PathAndQuery); + } + + private static string[] Strings(JsonElement document, string property) => + document.GetProperty(property).EnumerateArray().Select(x => x.GetString()!).ToArray(); +} + +public sealed class OidcSurfaceAbsentTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public OidcSurfaceAbsentTests(TestWebApplicationFactory factory) => _factory = factory; + + [Theory] + [InlineData("/.well-known/openid-configuration")] + [InlineData("/.well-known/jwks")] + [InlineData("/connect/authorize")] + [InlineData("/connect/token")] + [InlineData("/connect/userinfo")] + public async Task Without_the_configuration_section_no_provider_surface_exists(string path) + { + using var client = _factory.CreateClient(); + using var response = await client.GetAsync(path); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} + +public sealed class OidcStartupFailureTests +{ + [Theory] + [InlineData(OidcKeyFault.MissingFile)] + [InlineData(OidcKeyFault.RsaKey)] + [InlineData(OidcKeyFault.PublicKeyOnly)] + [InlineData(OidcKeyFault.NotPem)] + public void A_configured_provider_with_an_unusable_signing_key_stops_startup_naming_the_setting_not_the_path(OidcKeyFault fault) + { + using var factory = new OidcKeyFaultFactory(fault); + + var failure = Assert.ThrowsAny(() => factory.CreateClient()); + + var messages = Flatten(failure).ToList(); + Assert.Contains(messages, m => m.Contains("Bifrost:Oidc:SigningKeyPath", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("canary-", StringComparison.Ordinal)); + Assert.DoesNotContain(messages, m => m.Contains("BEGIN", StringComparison.Ordinal)); + } + + private static IEnumerable Flatten(Exception error) + { + for (var current = error; current is not null; current = current.InnerException!) + { + yield return current.Message; + if (current.InnerException is null) + { + yield break; + } + } + } +} diff --git a/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs new file mode 100644 index 0000000..73d5200 --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcKeyMaterialTests.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using Bifrost.Web.Configuration; +using Bifrost.Web.Features.Oidc; +using Bifrost.Web.Tests.Support; +using Microsoft.IdentityModel.Tokens; + +namespace Bifrost.Web.Tests; + +public sealed class OidcKeyMaterialTests : IDisposable +{ + private readonly OidcTestKit _kit = new(); + + public void Dispose() => _kit.Dispose(); + + [Fact] + public void A_complete_configuration_loads_the_signing_key_the_secret_and_the_encryption_key() + { + using var material = OidcKeyMaterial.Load(Options()); + + var signing = Assert.IsType(material.SigningKey); + Assert.False(string.IsNullOrEmpty(signing.KeyId)); + Assert.Equal(_kit.ClientSecret, material.ClientSecret); + Assert.Equal(32, Assert.IsType(material.EncryptionKey).Key.Length); + } + + [Theory] + [InlineData("SigningKeyPath", "Bifrost:Oidc:SigningKeyPath")] + [InlineData("EncryptionKeyPath", "Bifrost:Oidc:EncryptionKeyPath")] + [InlineData("ClientId", "Bifrost:Oidc:Clients:Mas:ClientId")] + [InlineData("ClientSecretPath", "Bifrost:Oidc:Clients:Mas:ClientSecretPath")] + [InlineData("RedirectUri", "Bifrost:Oidc:Clients:Mas:RedirectUri")] + public void A_missing_setting_stops_startup_naming_that_setting(string blank, string expectedField) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(blank: blank))); + + Assert.Contains(expectedField, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_redirect_uri_that_is_not_absolute_stops_startup() + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(redirectUri: "/callback"))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:RedirectUri", failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("missing")] + [InlineData("not base64 !!")] + [InlineData("AAAAAAAAAAAAAAAAAAAAAA==")] + public void An_unusable_encryption_key_stops_startup_naming_the_setting_not_the_path(string content) + { + if (content == "missing") + { + File.Delete(_kit.EncryptionKeyPath); + } + else + { + File.WriteAllText(_kit.EncryptionKeyPath, content); + } + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:EncryptionKeyPath", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("canary-", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_missing_client_secret_file_stops_startup_naming_the_setting_not_the_path() + { + File.Delete(_kit.ClientSecretPath); + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:ClientSecretPath", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("canary-", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_P384_key_is_not_a_P256_key() + { + using var p384 = ECDsa.Create(ECCurve.NamedCurves.nistP384); + File.WriteAllText(_kit.SigningKeyPath, p384.ExportPkcs8PrivateKeyPem()); + + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options())); + + Assert.Contains("Bifrost:Oidc:SigningKeyPath", failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("not a uri")] + [InlineData("/upstream/backchannel-logout/01ABC")] + [InlineData("ftp://mas.example.test/upstream/backchannel-logout/01ABC")] + public void A_back_channel_logout_uri_that_is_not_absolute_http_stops_startup_naming_the_setting_not_the_value(string uri) + { + var failure = Assert.Throws(() => OidcKeyMaterial.Load(Options(logoutUri: uri))); + + Assert.Contains("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(uri, failure.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("https://mas.example.test/upstream/backchannel-logout/01ABC")] + [InlineData("http://mas.internal/upstream/backchannel-logout/01ABC")] + public void An_absent_or_absolute_http_back_channel_logout_uri_loads(string uri) + { + using var material = OidcKeyMaterial.Load(Options(logoutUri: uri)); + + Assert.NotNull(material.SigningKey); + } + + private OidcIssuerOptions Options(string? blank = null, string? redirectUri = null, string logoutUri = "") => new() + { + SigningKeyPath = blank == "SigningKeyPath" ? "" : _kit.SigningKeyPath, + EncryptionKeyPath = blank == "EncryptionKeyPath" ? "" : _kit.EncryptionKeyPath, + Clients = new OidcClientsOptions + { + Mas = new OidcClientOptions + { + ClientId = blank == "ClientId" ? "" : OidcTestKit.ClientId, + ClientSecretPath = blank == "ClientSecretPath" ? "" : _kit.ClientSecretPath, + RedirectUri = blank == "RedirectUri" ? "" : redirectUri ?? OidcTestKit.RedirectUri, + BackchannelLogoutUri = logoutUri + } + } + }; +} 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 new file mode 100644 index 0000000..c2e5b0f --- /dev/null +++ b/tests/Bifrost.Web.Tests/OidcLogoutTests.cs @@ -0,0 +1,533 @@ +using System.Text.Json; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Oidc; +using Bifrost.Web.Tests.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using OpenIddict.Abstractions; + +namespace Bifrost.Web.Tests; + +public sealed class OidcLogoutTests +{ + private static readonly TimeSpan[] ExpectedBackoff = + new[] { 30, 60, 120, 240, 480, 900, 900 }.Select(x => TimeSpan.FromSeconds(x)).ToArray(); + + [Fact] + public async Task Probe_authorizations_carry_the_subject_and_a_valid_status_after_a_code_flow() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(); + + Assert.True(factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) > 0); + using var scope = factory.Services.CreateScope(); + var manager = scope.ServiceProvider.GetRequiredService(); + var found = new List(); + await foreach (var authorization in manager.FindBySubjectAsync(factory.AccountId(identity).ToString("D"))) + { + found.Add(authorization); + } + + Assert.NotEmpty(found); + Assert.True(await manager.TryRevokeAsync(found[0])); + } + + [Theory] + [InlineData(MembershipStatus.Suspended)] + 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); + var subject = factory.AccountId(identity).ToString("D"); + var reconciler = factory.Reconciler; + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, new TokenValidationParameters + { + ValidIssuer = OidcLogoutFactory.Origin.AbsoluteUri, + ValidAudience = OidcTestKit.ClientId, + IssuerSigningKeys = (await factory.JwksAsync()).GetSigningKeys(), + ValidAlgorithms = ["ES256"], + ValidTypes = ["logout+jwt"], + ValidateLifetime = false + }); + Assert.True(result.IsValid, result.Exception?.Message); + var jwt = (JsonWebToken)result.SecurityToken; + Assert.Equal("logout+jwt", jwt.Typ); + Assert.Equal(new JsonWebToken(idToken).Issuer, jwt.Issuer); + Assert.Equal(subject, jwt.Subject); + Assert.Equal(TimeSpan.FromMinutes(2), jwt.ValidTo - jwt.IssuedAt); + Assert.Equal(factory.Time.GetUtcNow().ToUnixTimeSeconds(), new DateTimeOffset(jwt.IssuedAt).ToUnixTimeSeconds()); + Assert.False(string.IsNullOrEmpty(jwt.Id)); + var payload = JsonDocument.Parse(Base64UrlEncoder.Decode(jwt.EncodedPayload)).RootElement; + var events = payload.GetProperty("events"); + Assert.Equal(["http://schemas.openid.net/event/backchannel-logout"], events.EnumerateObject().Select(x => x.Name).ToArray()); + Assert.Equal(JsonValueKind.Object, events.EnumerateObject().Single().Value.ValueKind); + Assert.False(payload.TryGetProperty("nonce", out _)); + Assert.False(payload.TryGetProperty("sid", out _)); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + 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() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(500, 200); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var ids = factory.Mas.LogoutTokens().Select(x => new JsonWebToken(x).Id).ToArray(); + Assert.Equal(2, ids.Length); + Assert.NotEqual(ids[0], ids[1]); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D"))); + } + + [Fact] + public async Task An_active_member_is_never_sent_and_keeps_the_authorizations() + { + using var factory = new OidcLogoutFactory(); + var (suspended, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (active, _) = await factory.GrantAsync(MembershipStatus.Active); + var activeSubject = factory.AccountId(active).ToString("D"); + var before = factory.ValidAuthorizations(activeSubject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + Assert.Equal(factory.AccountId(suspended).ToString("D"), new JsonWebToken(token).Subject); + 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() + { + using var factory = new OidcLogoutFactory(); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(500); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + var delivered = new[] { first, second }.Count(x => factory.ValidAuthorizations(factory.AccountId(x).ToString("D")) == 0); + Assert.Equal(1, delivered); + } + + [Fact] + public async Task Delivery_retries_with_exponential_capped_backoff_and_revokes_only_after_a_2xx() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500, 500, 500, 500, 500, 500, 500, 200); + var reconciler = factory.Reconciler; + + var posts = 0; + foreach (var delay in ExpectedBackoff) + { + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(++posts, factory.Mas.Bodies.Count); + Assert.True(factory.ValidAuthorizations(subject) > 0, "a failed delivery must not revoke"); + + factory.Time.Advance(delay - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(posts, factory.Mas.Bodies.Count); + + factory.Time.Advance(TimeSpan.FromSeconds(1)); + } + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(8, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Theory] + [InlineData(FakeMasHandler.NetworkFault)] + [InlineData(FakeMasHandler.Timeout)] + [InlineData(300)] + [InlineData(302)] + [InlineData(401)] + public async Task A_network_fault_timeout_or_unexpected_status_keeps_the_authorizations_and_retries(int fault) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(fault); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.True(factory.ValidAuthorizations(subject) > 0); + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task A_restarted_reconciler_resumes_the_pending_subject_from_the_table() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + await factory.NewReconciler().ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Theory] + [InlineData(400)] + [InlineData(404)] + public async Task A_400_or_404_is_a_fault_not_success_and_backs_off_at_the_cap(int status) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(status, status); + var reconciler = factory.Reconciler; + + await reconciler.ReconcileOnceAsync(CancellationToken.None); + factory.Time.Advance(OidcLogoutReconciler.MaxBackoff - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + Assert.True(factory.ValidAuthorizations(subject) > 0); + + factory.Time.Advance(TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + factory.Time.Advance(OidcLogoutReconciler.MaxBackoff - TimeSpan.FromSeconds(1)); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.True(factory.ValidAuthorizations(subject) > 0); + } + + [Fact] + public async Task A_subject_that_is_not_an_account_id_is_treated_as_not_active() + { + using var factory = new OidcLogoutFactory(); + var (active, _) = await factory.GrantAsync(MembershipStatus.Active); + await factory.AddAuthorizationAsync("not-a-guid"); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + var token = Assert.Single(factory.Mas.LogoutTokens()); + Assert.Equal("not-a-guid", new JsonWebToken(token).Subject); + Assert.Equal(0, factory.ValidAuthorizations("not-a-guid")); + Assert.True(factory.ValidAuthorizations(factory.AccountId(active).ToString("D")) > 0); + } + + [Fact] + public async Task A_deleted_account_is_treated_as_not_active() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Active); + var subject = factory.AccountId(identity).ToString("D"); + await factory.DeleteAccountAsync(identity); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(subject, new JsonWebToken(Assert.Single(factory.Mas.LogoutTokens())).Subject); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Theory] + [InlineData(201)] + [InlineData(204)] + [InlineData(299)] + public async Task Any_2xx_is_delivery(int status) + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(status); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(identity).ToString("D"))); + } + + [Fact] + public async Task A_failure_is_logged_by_status_and_delay_only_and_a_success_logs_no_failure() + { + using var factory = new OidcLogoutFactory(); + await factory.GrantAsync(MembershipStatus.Suspended); + await factory.GrantAsync(MembershipStatus.Suspended); + var log = new ListLogger(); + factory.Mas.Script(503, FakeMasHandler.NetworkFault); + + await factory.NewReconciler(logger: log).ReconcileOnceAsync(CancellationToken.None); + + // The first subject failed with 503, the second with no response; both were delivered nothing. + var warnings = log.Entries.Where(x => x.Level == LogLevel.Warning).Select(x => x.Message).ToArray(); + Assert.Equal(2, warnings.Length); + Assert.Contains(warnings, x => x.Contains("503", StringComparison.Ordinal) && x.Contains("00:00:30", StringComparison.Ordinal)); + Assert.Contains(warnings, x => x.Contains("no response", StringComparison.Ordinal)); + Assert.All(log.Entries, x => + { + Assert.DoesNotContain(FakeMasHandler.BodyCanary, x.Message, StringComparison.Ordinal); + Assert.DoesNotContain("mas.example.test", x.Message, StringComparison.Ordinal); + Assert.DoesNotContain("logout_token", x.Message, StringComparison.Ordinal); + }); + + var clean = new ListLogger(); + await factory.NewReconciler(logger: clean).ReconcileOnceAsync(CancellationToken.None); + Assert.Empty(clean.Entries.Where(x => x.Level >= LogLevel.Warning)); + } + + [Fact] + public async Task A_subject_who_recovers_or_whose_grants_vanish_carries_no_stale_backoff() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + factory.Mas.Script(500, 500); + var reconciler = factory.Reconciler; + + // Recovered: Active again clears the backoff, so the next demotion is delivered at once. + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Single(factory.Mas.Bodies); + await factory.SetMembershipAsync(identity, MembershipStatus.Active); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + await factory.SetMembershipAsync(identity, MembershipStatus.Suspended); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + + // Vanished: grants revoked elsewhere drop the subject from the list, which drops its backoff. + await factory.RevokeAllAsync(subject); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + await factory.AddAuthorizationAsync(subject); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task One_subject_that_throws_unexpectedly_does_not_stop_the_pass_and_keeps_its_authorizations() + { + using var factory = new OidcLogoutFactory(); + var log = new ListLogger(); + var reconciler = factory.NewReconciler(logger: log); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subjects = new[] { first, second }.Select(x => factory.AccountId(x).ToString("D")).ToArray(); + + // Whichever subject the pass reaches first throws; the other is still logged out and revoked. + factory.Mas.Script(FakeMasHandler.Unexpected); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(2, factory.Mas.Bodies.Count); + var firstToken = factory.Mas.LogoutTokens()[0]; + var thrown = new JsonWebToken(firstToken).Subject; + var other = subjects.Single(x => x != thrown); + Assert.Equal(0, factory.ValidAuthorizations(other)); + Assert.True(factory.ValidAuthorizations(thrown) > 0); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(thrown, failure.Message, StringComparison.Ordinal); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(firstToken, failure.Message, StringComparison.Ordinal); + + // The failed subject was not backed off: the next pass tries it again and delivers. + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(3, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(thrown)); + } + + [Fact] + public async Task A_cancelled_post_stops_the_pass_and_is_not_a_failed_delivery() + { + using var factory = new OidcLogoutFactory(); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + var reconciler = factory.Reconciler; + + // Stopping while the post is in flight surfaces as cancellation and records no backoff: the next pass posts at once. + factory.Mas.Script(FakeMasHandler.Hang); + using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + await Assert.ThrowsAnyAsync(() => reconciler.ReconcileOnceAsync(stop.Token)); + Assert.Single(factory.Mas.Bodies); + await reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(2, factory.Mas.Bodies.Count); + Assert.Equal(0, factory.ValidAuthorizations(subject)); + } + + [Fact] + public async Task A_redirect_from_mas_is_not_followed_and_is_not_delivery() + { + using var mas = new RedirectingMas(); + using var factory = new OidcLogoutFactory(mas.LogoutUri); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + var subject = factory.AccountId(identity).ToString("D"); + var before = factory.ValidAuthorizations(subject); + Assert.True(before > 0); + + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + + Assert.Equal(["POST /logout"], mas.Requests); + Assert.Equal(before, factory.ValidAuthorizations(subject)); + + // A redirect is a non-delivery like any non-2xx: it backs off, then posts again. + factory.Time.Advance(TimeSpan.FromSeconds(30)); + await factory.Reconciler.ReconcileOnceAsync(CancellationToken.None); + Assert.Equal(["POST /logout", "POST /logout"], mas.Requests); + Assert.Equal(before, factory.ValidAuthorizations(subject)); + } + + [Fact] + public void The_logout_client_times_out_after_ten_seconds() + { + using var factory = new OidcLogoutFactory(); + using var client = factory.CreateClient(); + + Assert.Equal(TimeSpan.FromSeconds(10), OidcLogoutReconciler.RequestTimeout); + Assert.Equal( + TimeSpan.FromSeconds(10), + factory.Services.GetRequiredService().CreateClient(OidcLogoutReconciler.HttpClientName).Timeout); + } + + [Fact] + public async Task The_hosted_loop_ticks_again_after_each_interval_and_survives_a_failed_tick() + { + using var factory = new OidcLogoutFactory(); + var clock = new ControlledClock(); + var log = new ListLogger(); + var reconciler = factory.NewReconciler(clock, log); + var (first, _) = await factory.GrantAsync(MembershipStatus.Suspended); + await reconciler.StartAsync(CancellationToken.None); + try + { + await WaitForAsync(() => factory.Mas.Bodies.Count == 1); + Assert.Equal(0, factory.ValidAuthorizations(factory.AccountId(first).ToString("D"))); + + // A failing tick is logged and retried only after the interval, never in a tight loop. + var (second, _) = await factory.GrantAsync(MembershipStatus.Suspended); + factory.Mas.Script(FakeMasHandler.Unexpected); + await WaitForAsync(() => clock.Waiting > 0); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 2); + await Task.Delay(300); + Assert.Equal(2, factory.Mas.Bodies.Count); + + await WaitForAsync(() => clock.Waiting > 0); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 3); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain("not a network fault", failure.Message, StringComparison.Ordinal); + await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(second).ToString("D")) == 0); + } + finally + { + await reconciler.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task A_pass_that_cannot_read_its_subjects_is_logged_by_type_and_retried_after_the_interval() + { + using var factory = new OidcLogoutFactory(); + var clock = new ControlledClock(); + var log = new ListLogger(); + var flaky = new FlakyScopeFactory(factory.Services.GetRequiredService(), failures: 1); + var reconciler = factory.NewReconciler(clock, log, flaky); + var (identity, _) = await factory.GrantAsync(MembershipStatus.Suspended); + await reconciler.StartAsync(CancellationToken.None); + try + { + await WaitForAsync(() => log.Entries.Any(x => x.Level == LogLevel.Error) && clock.Waiting > 0); + var failure = Assert.Single(log.Entries, x => x.Level == LogLevel.Error); + Assert.Contains(nameof(InvalidOperationException), failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(FlakyScopeFactory.Secret, failure.Message, StringComparison.Ordinal); + + // Never a tight loop: nothing posts until the interval passes, then the pass succeeds. + await Task.Delay(300); + Assert.Empty(factory.Mas.Bodies); + clock.FireTimers(); + await WaitForAsync(() => factory.Mas.Bodies.Count == 1); + await WaitForAsync(() => factory.ValidAuthorizations(factory.AccountId(identity).ToString("D")) == 0); + } + finally + { + await reconciler.StopAsync(CancellationToken.None); + } + } + + private static async Task WaitForAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition()) + { + Assert.True(DateTime.UtcNow < deadline, "timed out waiting for the reconciler"); + await Task.Delay(20); + } + } + + [Fact] + public void Without_a_back_channel_logout_uri_the_reconciler_is_not_hosted_and_nothing_is_revoked() + { + using var factory = new OidcWebApplicationFactory(); + using var client = factory.CreateClient(); + + Assert.Empty(factory.Services.GetServices().OfType()); + } + + [Fact] + public void The_reconciler_is_hosted_when_the_uri_is_set() + { + using var factory = new OidcLogoutFactory(); + using var client = factory.CreateClient(); + + Assert.NotNull(factory.Reconciler); + } +} 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 new file mode 100644 index 0000000..f231758 --- /dev/null +++ b/tests/persona-matrix-mouth-runtime.test.mjs @@ -0,0 +1,767 @@ +// 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 (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"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +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(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, + context: [{ sender: "@eureka:gamecult.org", text: "earlier", atUnixMs: 1761699999000 }], + mentions: ["@eureka:gamecult.org", "@nova:gamecult.org"], isDirect: true, image: null, +}; +const image = { path: `${"cd".repeat(32)}/${"ab".repeat(32)}.png`, mime: "image/png", size: 2048, sha256: "ab".repeat(32) }; +const samples = { + [docs.kinds.pendingRequest]: { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest }, + [docs.kinds.ack]: { tuple: docs.ackTuple({ persona: "eureka", throughSeq: 7 }), parse: docs.parseAck }, + [docs.kinds.sendRequest]: { tuple: docs.sendRequestTuple({ requestId: "req-1", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "hi" }), parse: docs.parseSendRequest }, + [docs.kinds.sendReceipt]: { tuple: docs.sendReceiptTuple({ requestId: "req-1", eventId: "$sent" }), parse: docs.parseSendReceipt }, + [docs.kinds.refusal]: { tuple: docs.refusalTuple({ persona: "ghost", refusal: "UnknownPersona" }), parse: docs.parseRefusal }, +}; + +for (const [kind, { tuple, parse }] of Object.entries(samples)) { + test(`golden fixture: ${kind} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(fixtureDir, `${kind}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(fixtureDir, { 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 chat documents refuse malformed tuples", () => { + assert.throws(() => docs.parseAck(["eureka"])); + assert.throws(() => docs.parseAck(["eureka", -1])); + assert.throws(() => docs.parsePendingRequest(["", 0])); + assert.throws(() => docs.parseSendRequest(["r", "eureka", "#a:b", "x".repeat(4001)])); + assert.throws(() => docs.parseSendRequest(["r", "eureka", "#a:b", 5])); + const wire = docs.deliveryTuple(delivery); + assert.equal(wire.length, 12); + assert.deepEqual(docs.parseDelivery(wire), delivery); + assert.throws(() => docs.parseDelivery(wire.slice(0, 11)), /12-field/, "the eleven-field delivery is gone: one shape"); + assert.throws(() => docs.parseDelivery([...wire.slice(0, 10), "yes", null]), /boolean/); + assert.throws(() => docs.parseDelivery([...wire.slice(0, 9), [""], true, null])); + assert.throws(() => docs.refusalTuple({ persona: "p", refusal: "Invented" })); + assert.throws(() => docs.parseSendReceipt(["r", "$e", "Brake"]), /exactly one/); + assert.throws(() => docs.parseSendReceipt(["r", null, null]), /exactly one/); + assert.throws(() => docs.sendReceiptTuple({ requestId: "r", refusal: "Invented" })); + assert.throws(() => docs.parseSendReceipt(["r", null, "Invented"]), /unknown refusal/); + assert.deepEqual(docs.parseSendReceipt(["r", null, "Brake"]), { requestId: "r", eventId: null, refusal: "Brake" }); +}); + +test("the cursor is a CultCache file that survives a restart", async () => { + const path = resolve(await mkdtemp(resolve(tmpdir(), "mouth-cursor-")), "cursor.cc"); + assert.equal(await cultcacheState({ runtime, path }).load(), null); + await cultcacheState({ runtime, path }).save({ nextBatch: "s72_9", seq: 12 }); + const loaded = await cultcacheState({ runtime, path }).load(); + assert.deepEqual([loaded.nextBatch, loaded.seq], ["s72_9", 12]); +}); + +async function writeControl(path, brakeTuple) { + const store = new runtime.SingleFileMessagePackBackingStore(path); + await store.push({ key: "brake", type: "eureka.control.brake.v1", payload: msgpack.encode(brakeTuple), storedAt: new Date().toISOString() }); +} + +test("the brake reader reads a real CultCache control store", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "mouth-control-")); + const reader = path => createBrakeReader({ controlPath: path, readStore: runtime.readSingleFileStore, decode: msgpack.decode }); + assert.equal(await reader(resolve(dir, "missing.cc"))(), false); + await writeControl(resolve(dir, "held.cc"), [false, "2026-10-29T00:00:00Z", "op"]); + assert.equal(await reader(resolve(dir, "held.cc"))(), false); + await writeControl(resolve(dir, "released.cc"), [true, "2026-10-29T00:00:00Z", "op"]); + assert.equal(await reader(resolve(dir, "released.cc"))(), true); + await writeFile(resolve(dir, "garbage.cc"), "not a store"); + assert.equal(await reader(resolve(dir, "garbage.cc"))(), false); +}); + +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 }; }, + }; + const key = "connection-key-for-concurrency"; + const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test" }); + const dial = async name => { + const socket = dgram.createSocket("udp4"); + await new Promise(ok => socket.bind(0, "127.0.0.1", ok)); + const connection = new runtime.cultnet.CultNetRudpSocketTransportConnection({ + runtimeId: name, socket, mode: "client", remoteHost: "127.0.0.1", remotePort: server.address.port, + connectionId: connectionIdFor(key), resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200, + }); + const receipts = []; + connection.on("frame", value => { + const message = runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0"); + receipts.push(docs.parseSendReceipt(msgpack.decode(message.document.payload))); + }); + connection.connect(); + for (let i = 0; i < 100 && !connection.connected; i++) await new Promise(r => setTimeout(r, 20)); + assert.ok(connection.connected, `${name} opened its session`); + const send = requestId => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind: docs.kinds.sendRequest, recordKey: requestId, payload: msgpack.encode(docs.sendRequestTuple({ requestId, persona: "eureka", roomAlias: "#operator:gamecult.org", text: name })), sourceRuntimeId: name, sourceAgentId: "eureka", sourceRole: "persona-organ" }), "cultnet.schema.v0"))); + return { connection, receipts, send }; + }; + const first = await dial("mind-persona-one"); + const second = await dial("mind-heartbeat"); + try { + first.send("req-first"); + second.send("req-second"); + for (let i = 0; i < 250 && (first.receipts.length < 1 || second.receipts.length < 1); i++) await new Promise(r => setTimeout(r, 20)); + assert.deepEqual(first.receipts, [{ requestId: "req-first", eventId: "$req-first", refusal: null }]); + assert.deepEqual(second.receipts, [{ requestId: "req-second", eventId: "$req-second", refusal: null }]); + assert.ok(first.connection.connected && second.connection.connected, "neither session took the other's"); + } finally { + server.close(); + first.connection.close(); + second.connection.close(); + } +}); + +test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a held brake with a Brake refusal", async () => { + 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 }; }, + }; + const key = "connection-key-for-test"; + const rejected = []; + const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test", log: line => rejected.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 => { + const message = runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0"); + replies.push(message.document); + waiting.splice(0).forEach(w => w()); + }); + const nextReply = () => new Promise((ok, fail) => { const t = setTimeout(() => fail(new Error(`no reply from the mouth; it logged: ${rejected.join(" | ")}`)), 5000); waiting.push(() => { clearTimeout(t); ok(replies.at(-1)); }); }); + const put = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-test", sourceAgentId: "eureka", 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, "the RUDP session opens with the credential-derived connection id"); + let reply = nextReply(); + put(docs.kinds.pendingRequest, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + let document = await reply; + assert.equal(document.schemaId, docs.kinds.pending); + 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(); + put(docs.kinds.sendRequest, "req-2", docs.sendRequestTuple({ requestId: "req-2", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "muted" })); + document = await reply; + assert.equal(document.schemaId, docs.kinds.sendReceipt); + assert.deepEqual(docs.parseSendReceipt(msgpack.decode(document.payload)), { requestId: "req-2", eventId: null, refusal: "Brake" }); + reply = nextReply(); + put(docs.kinds.ack, "other:9", docs.ackTuple({ persona: "other", throughSeq: 9 })); + document = await reply; + assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload))], [docs.kinds.refusal, { persona: "other", refusal: "UnknownPersona" }], "an ack for an unlisted Persona is refused, typed, and not applied"); + assert.deepEqual(calls.acks, [7]); + + reply = nextReply(); + put(docs.kinds.read, "other:#operator:gamecult.org", docs.readTuple({ persona: "other", roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 5 })); + document = await reply; + assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload))], [docs.kinds.refusal, { persona: "other", refusal: "UnknownPersona" }], "a read report for an unlisted Persona is refused, typed, and not applied"); + assert.deepEqual(calls.reads, []); + + put(docs.kinds.read, "eureka:#operator:gamecult.org", docs.readTuple({ persona: "eureka", roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 5 })); + for (let i = 0; i < 100 && calls.reads.length < 1; i++) await new Promise(r => setTimeout(r, 20)); + assert.deepEqual(calls.reads, [{ persona: "eureka", roomAlias: "#operator:gamecult.org", eventId: "$e1", atUnixMs: 5 }], "her own report reaches her mouth with its persona"); + + reply = nextReply(); + put(docs.kinds.pendingRequest, "other", docs.pendingRequestTuple({ persona: "other", afterSeq: 0 })); + document = await reply; + assert.deepEqual([document.schemaId, docs.parseRefusal(msgpack.decode(document.payload)).refusal], [docs.kinds.refusal, "UnknownPersona"], "unknown_persona_refused"); + + reply = nextReply(); + put(docs.kinds.sendRequest, "req-9", docs.sendRequestTuple({ requestId: "req-9", persona: "other", roomAlias: "#operator:gamecult.org", text: "speak" })); + document = await reply; + assert.deepEqual(docs.parseSendReceipt(msgpack.decode(document.payload)), { requestId: "req-9", eventId: null, refusal: "UnknownPersona" }); + assert.equal(calls.sends.length, 1, "nothing reached a mouth for the unlisted Persona"); + } finally { + server.close(); + connection.close(); + try { client.close(); } catch { /* the connection already closed its socket */ } + } +}); + +test("the RUDP endpoint refuses to listen beyond loopback", async () => { + await assert.rejects(startChatEndpoint({ mouths: new Map(), runtime, port: 0, host: "0.0.0.0", connectionKey: "k", runtimeId: "m" }), /loopback/); +}); + +test("loopback means exactly an IPv4 127.x.y.z address", async () => { + const refuses = host => assert.rejects(startChatEndpoint({ mouths: new Map(), runtime, port: 0, host, connectionKey: "k", runtimeId: "m" }), /loopback/); + for (const host of ["9127.0.0.1", "127.0.0.1x", "10.127.0.0.1", "127.0.0", ""]) await refuses(host); + const ok = await startChatEndpoint({ mouths: new Map(), runtime, port: 0, host: "127.10.20.30", connectionKey: "k", runtimeId: "m" }).catch(e => e); + assert.ok(!/loopback/.test(ok?.message ?? ""), "a multi-digit loopback address is accepted by the check"); + ok.close?.(); +}); + +test("one process serves N Personas from systemd credentials: each token only in its own requests, nothing joined while braked, and no token on stderr", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "mouth-main-")); + const creds = resolve(dir, "creds"), control = resolve(dir, "control"), stateDir = resolve(dir, "state"); + const tokens = { eureka: "syt_AAA_eureka_token", nova: "syt_BBB_nova_token" }; + await mkdir(creds, { recursive: true }); + for (const [persona, token] of Object.entries(tokens)) await writeFile(resolve(creds, `matrix-token-${persona}`), `${token}\n`); + await writeFile(resolve(creds, "chat-key"), "chat-key-credential\n"); + await mkdir(resolve(control, "eureka"), { recursive: true }); + await mkdir(resolve(control, "nova"), { recursive: true }); + await writeControl(resolve(control, "eureka", "control.cc"), [true, "2026-10-29T00:00:00Z", "op"]); + await writeControl(resolve(control, "nova", "control.cc"), [false, "2026-10-29T00:00:00Z", "op"]); // nova is braked + + const seen = []; + const joinedBy = new Map(); + const hs = createServer((req, res) => { + const auth = req.headers.authorization ?? ""; + const path = decodeURIComponent(new URL(req.url, "http://x").pathname).replace("/_matrix/client/v3", ""); + seen.push({ auth, method: req.method, path }); + const reply = body => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; + if (path === "/account/whoami") return reply({ user_id: auth.includes(tokens.eureka) ? "@eureka:t" : "@nova:t" }); + if (path === "/joined_rooms") return reply({ joined_rooms: [...(joinedBy.get(auth) ?? [])] }); + if (path.startsWith("/join/")) { joinedBy.set(auth, (joinedBy.get(auth) ?? new Set()).add(path.slice(6))); return reply({ room_id: path.slice(6) }); } + if (path.startsWith("/directory/room/")) return reply({ room_id: "!swarm:t" }); + if (path === "/sync") return reply({ next_batch: "b0" }); + res.writeHead(404); res.end("{}"); + }); + await new Promise(ok => hs.listen(0, "127.0.0.1", ok)); + const probe = dgram.createSocket("udp4"); + await new Promise(ok => probe.bind(0, "127.0.0.1", ok)); + const port = probe.address().port; + probe.close(); + const child = spawn(process.execPath, [resolve(root, "tools", "persona-matrix-mouth.mjs")], { + env: { + 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}`, + }, + }); + let stderr = ""; + child.stderr.on("data", c => (stderr += c)); + try { + for (let i = 0; i < 100 && !(seen.some(r => r.path === "/sync" && r.auth.includes(tokens.nova)) && seen.some(r => r.path === "/sync" && r.auth.includes(tokens.eureka))); i++) await new Promise(r => setTimeout(r, 50)); + const by = persona => seen.filter(r => r.auth === `Bearer ${tokens[persona]}`); + assert.ok(by("eureka").some(r => r.path === "/sync") && by("nova").some(r => r.path === "/sync"), `both accounts sync from one process; stderr: ${stderr}`); + assert.equal(by("eureka").length + by("nova").length, seen.length, "every request carries exactly one Persona's token"); + assert.deepEqual(by("eureka").filter(r => r.method === "POST").map(r => r.path), ["/join/!swarm:t"], "the released Persona joins"); + assert.deepEqual(by("nova").filter(r => r.method === "POST"), [], "the braked Persona joins nothing"); + } finally { + child.kill(); + await new Promise(ok => hs.close(ok)); + } + assert.ok(!Object.values(tokens).some(t => stderr.includes(t)) && !stderr.includes("chat-key-credential"), "no secret on stderr"); +}); + +test("a reply is paged to the byte budget, oldest first, and one delivery that will not encode is skipped and logged without silencing the rest", () => { + const mk = (n, body) => ({ ...delivery, seq: n, eventId: `$e${n}`, text: body }); + const logs = []; + const page = pageOf("eureka", [mk(1, "ok"), { ...mk(2, "bad"), sender: 123 }, mk(3, "fine")], msgpack, l => logs.push(l)); + assert.deepEqual(page[1].map(t => t[0]), [1, 3]); + assert.equal(logs.length, 1); + assert.ok(logs[0].includes("delivery 2 skipped")); + + const bodies = Array.from({ length: 200 }, (_, i) => mk(i + 1, "x".repeat(3900))); + const paged = pageOf("eureka", bodies, msgpack); + const size = msgpack.encode(paged).length; + assert.ok(size <= docs.limits.replyBytes + 1000, `page of ${size} bytes is over the budget`); + assert.ok(paged[1].length > 1 && paged[1].length < 200); + assert.deepEqual(paged[1].map(t => t[0]), paged[1].map((_, i) => i + 1), "the page is the oldest deliveries"); + assert.equal(pageOf("eureka", [mk(1, "x".repeat(4000))], msgpack)[1].length, 1, "a single delivery is always served"); +}); + +// The pending answer v2 (ChatDelivery v3): the bytes Huginn pins in its crates/mind-body/tests/fixtures/persona-chat-v3/. +const v3Dir = resolve(root, "tests", "fixtures", "persona-chat-v3"); +const v3Samples = { + "gamecult.persona.chat_pending.v2.image": { tuple: docs.pendingTuple({ persona: "eureka", deliveries: [{ ...delivery, image }] }), parse: docs.parsePending }, + "gamecult.persona.chat_pending.v2.no-image": { tuple: docs.pendingTuple({ persona: "eureka", deliveries: [delivery] }), parse: docs.parsePending }, +}; +for (const [name, { tuple, parse }] of Object.entries(v3Samples)) { + test(`golden fixture: ${name} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(v3Dir, `${name}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v3Dir, { 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)); + assert.equal(msgpack.decode(bytes)[1][0].length, 12); + }); +} + +// 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 }); +const v2Samples = { + "gamecult.persona.chat_send_request.v2.nil-basis": { tuple: v2Request(null), parse: docs.parseSendRequestV2 }, + "gamecult.persona.chat_send_request.v2.seen-and-since": { tuple: v2Request({ seenEventId: "$seen", sinceUnixMs: 1793698425998 }), parse: docs.parseSendRequestV2 }, + "gamecult.persona.chat_send_request.v2.since-only": { tuple: v2Request({ seenEventId: null, sinceUnixMs: 1793698425998 }), parse: docs.parseSendRequestV2 }, + "gamecult.persona.chat_send_receipt.v2.sent": { tuple: docs.sendReceiptV2Tuple({ requestId: "req-1", eventId: "$sent" }), parse: docs.parseSendReceiptV2 }, + "gamecult.persona.chat_send_receipt.v2.room-moved": { tuple: docs.sendReceiptV2Tuple({ requestId: "req-1", refusal: "RoomMoved", newer: ["$a", "$b"] }), parse: docs.parseSendReceiptV2 }, +}; +for (const [name, { tuple, parse }] of Object.entries(v2Samples)) { + test(`golden fixture: ${name} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(v2Dir, `${name}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v2Dir, { 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("RoomMoved is v2 only, and the v2 documents are strict about arity and newer", () => { + assert.throws(() => docs.sendReceiptTuple({ requestId: "r", refusal: "RoomMoved" }), /unknown refusal/, "the v1 receipt cannot carry RoomMoved"); + assert.throws(() => docs.parseSendReceipt(["r", null, "RoomMoved"]), /unknown refusal/); + assert.throws(() => docs.refusalTuple({ persona: "p", refusal: "RoomMoved" })); + assert.throws(() => docs.sendReceiptV2Tuple({ requestId: "r", refusal: "RoomMoved" }), /newer/, "RoomMoved names what moved the room"); + assert.throws(() => docs.sendReceiptV2Tuple({ requestId: "r", eventId: "$e", newer: ["$x"] }), /newer/, "a sent receipt names nothing"); + assert.throws(() => docs.sendReceiptV2Tuple({ requestId: "r", refusal: "RoomMoved", newer: Array.from({ length: 51 }, (_, i) => `$${i}`) }), /50/); + assert.throws(() => docs.parseSendRequestV2(["r", "eureka", "#a:b", "x"]), /5-field/, "a v1 tuple is not a v2 request"); + assert.throws(() => docs.parseSendRequest(v2Request(null)), /4-field/, "a v2 tuple is not a v1 request"); + assert.throws(() => docs.parseSendRequestV2(["r", "eureka", "#a:b", "x", [null]]), /2-field/); + assert.throws(() => docs.parseSendRequestV2(["r", "eureka", "#a:b", "x", [null, -1]])); +}); + +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: () => [], 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: [] }; + } }; + const key = "connection-key-for-v2"; + const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test" }); + 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 = []; + connection.on("frame", value => replies.push(runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0").document)); + const put = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-test", sourceAgentId: "eureka", sourceRole: "persona-organ" }), "cultnet.schema.v0"))); + const reply = async n => { for (let i = 0; i < 250 && replies.length < n; i++) await new Promise(r => setTimeout(r, 20)); assert.ok(replies.length >= n, "the mouth answered"); return replies[n - 1]; }; + try { + connection.connect(); + for (let i = 0; i < 100 && !connection.connected; i++) await new Promise(r => setTimeout(r, 20)); + put(docs.kinds.sendRequest, "v1", docs.sendRequestTuple({ requestId: "v1", persona: "eureka", roomAlias: "#swarm:gamecult.org", text: "old" })); + let document = await reply(1); + assert.equal(document.schemaId, docs.kinds.sendReceipt); + assert.deepEqual(msgpack.decode(document.payload), ["v1", "$sent", null], "three fields, as the old organ parses them"); + put(docs.kinds.sendRequestV2, "v2", docs.sendRequestV2Tuple({ requestId: "v2", persona: "eureka", roomAlias: "#swarm:gamecult.org", text: "new", basis: { seenEventId: "$s", sinceUnixMs: 5 } })); + document = await reply(2); + assert.equal(document.schemaId, docs.kinds.sendReceiptV2); + assert.deepEqual(docs.parseSendReceiptV2(msgpack.decode(document.payload)), { requestId: "v2", eventId: null, refusal: "RoomMoved", newer: ["$a"] }); + assert.deepEqual(seen.map(r => r.basis), [undefined, { seenEventId: "$s", sinceUnixMs: 5 }], "v1 reaches the mouth with no basis, v2 with hers"); + put(docs.kinds.sendRequestV2, "v2-ghost", docs.sendRequestV2Tuple({ requestId: "v2-ghost", persona: "ghost", roomAlias: "#swarm:gamecult.org", text: "x" })); + document = await reply(3); + assert.deepEqual(docs.parseSendReceiptV2(msgpack.decode(document.payload)), { requestId: "v2-ghost", eventId: null, refusal: "UnknownPersona", newer: [] }); + } finally { + server.close(); + connection.close(); + try { client.close(); } catch { /* the connection already closed its socket */ } + } +}); + +test("over the RUDP endpoint: pending and an ack post no receipt, a read report posts one, and it never holds up the same peer's next request", async () => { + const receipts = []; + let release; + const held = new Promise(r => { release = r; }); + const json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + const message = { type: "m.room.message", event_id: "$m1", sender: "@alice:t", origin_server_ts: 5, content: { msgtype: "m.text", body: "hi" } }; + const fetch = async (url, init = {}) => { + const u = new URL(url), p = u.pathname.replace("/_matrix/client/v3", ""); + if (p === "/account/whoami") return json({ user_id: "@eureka:t" }); + if (p === "/joined_rooms") return json({ joined_rooms: ["!r:t"] }); + if (p.startsWith("/directory/room/")) return json({ room_id: "!r:t" }); + if (p.includes("/account_data/")) return json({}, 404); + if (p === "/sync") return json(u.searchParams.get("since") === "b0" ? { next_batch: "b1", rooms: { join: { "!r:t": { timeline: { events: [message] } } } } } : { next_batch: "b0" }); + if (p.includes("/messages")) return json({ chunk: [] }); + if (p.includes("/receipt/")) { receipts.push(p); await held; return json({}); } + return json({}, 404); + }; + const state = { load: async () => null, save: async () => {} }; + const mouth = createMouth({ persona: "eureka", homeserver: "http://hs.test", rooms: ["#r:t"], token: "tok", state, fetch, serverName: "t", brake: async () => true, syncTimeoutMs: 1 }); + await mouth.syncOnce(); await mouth.syncOnce(); + const key = "connection-key-for-receipts"; + const server = await startChatEndpoint({ mouths: new Map([["eureka", mouth]]), runtime, port: 0, connectionKey: key, runtimeId: "mouth-test" }); + 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 = []; + connection.on("frame", value => replies.push(runtime.cultnet.parseCultNetMessage(msgpack.decode(value.payload), "cultnet.schema.v0").document)); + const put = (kind, recordKey, tuple) => connection.send("schema", msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire( + docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: "huginn-test", sourceAgentId: "eureka", sourceRole: "persona-organ" }), "cultnet.schema.v0"))); + const waitFor = async (check, what) => { for (let i = 0; i < 250 && !check(); i++) await new Promise(r => setTimeout(r, 20)); assert.ok(check(), what); }; + try { + connection.connect(); + await waitFor(() => connection.connected, "session opens"); + put(docs.kinds.pendingRequest, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + await waitFor(() => replies.length === 1, "pending answered"); + assert.deepEqual(receipts, [], "a pending request posts no receipt"); + put(docs.kinds.read, "eureka:#r:t", docs.readTuple({ persona: "eureka", roomAlias: "#r:t", eventId: "$m1", atUnixMs: 6 })); + await waitFor(() => receipts.length === 1, "the read report posted her receipt"); + assert.ok(receipts[0].endsWith("/receipt/m.read/%24m1"), "at the reported event"); + put(docs.kinds.ack, "eureka:1", docs.ackTuple({ persona: "eureka", throughSeq: 1 })); + put(docs.kinds.pendingRequest, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + await waitFor(() => replies.length === 2, "the next request is answered while the receipt response is still held open"); + assert.deepEqual(docs.parsePending(msgpack.decode(replies[1].payload)).deliveries, [], "the ack applied"); + assert.equal(receipts.length, 1, "the ack posted no receipt of its own"); + } finally { + release(); + server.close(); + connection.close(); + 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-send-gate.test.mjs b/tests/persona-matrix-mouth-send-gate.test.mjs new file mode 100644 index 0000000..812c311 --- /dev/null +++ b/tests/persona-matrix-mouth-send-gate.test.mjs @@ -0,0 +1,225 @@ +// The mouth's send gate: a Persona's v2 send is refused, with no PUT, when the room moved past what she held, +// and the check and the PUT are one serial step per room across every Persona. The homeserver here +// answers /messages as Synapse does (start, chunk newest first, end; the types filter and limit honoured) +// and serves the chunk captured from #swarm on 2026-11-03, not a hand-made shape. +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createMouth, createRoomGate, movesRoom } from "../tools/persona-matrix-mouth.mjs"; + +const HS = "gamecult.org"; +const ROOM = "!0pZn8mTZ0FdY5-G99iOuD7JWWlF3yA_5p20yO1C8WeA"; +const ALIAS = `#swarm:${HS}`; +const captured = JSON.parse(readFileSync(resolve(import.meta.dirname, "fixtures", "persona-chat-v2", "messages-swarm-2026-11-03.json"), "utf8")); +const swarm = captured.chunk.slice().reverse(); // oldest first, 11 real events +const [question] = swarm; +const EUREKA = `@eureka:${HS}`; +const idsBy = (events, pred) => events.filter(pred).map(e => e.event_id); + +// A synthetic event, marked as such by its id: the capture holds no threaded reply, edit or notice. +const synthetic = (kind, ts, sender, content) => ({ type: "m.room.message", event_id: `$synthetic-${kind}`, sender, origin_server_ts: ts, content }); +const t0 = swarm.at(-1).origin_server_ts; +const threaded = synthetic("thread", t0 + 10, `@nibu:${HS}`, { msgtype: "m.text", body: "in a thread", "m.relates_to": { rel_type: "m.thread", event_id: question.event_id } }); +const edit = synthetic("replace", t0 + 20, `@aqua:${HS}`, { msgtype: "m.text", body: "* fixed", "m.new_content": { msgtype: "m.text", body: "fixed" }, "m.relates_to": { rel_type: "m.replace", event_id: swarm[1].event_id } }); +const notice = synthetic("notice", t0 + 30, `@root-watch:${HS}`, { msgtype: "m.notice", body: "sudo audit line" }); + +// users: bearer token -> user id. events: the room's history, oldest first. hooks.delay(kind) -> ms to hold a GET /messages or a PUT. +async function homeserver({ events = swarm, users, headStatus = 200, headHangs = false, delay = () => 0 } = {}) { + const room = [...events]; + const seen = []; + const sent = new Map(); + const server = createServer((req, res) => { + let raw = ""; + req.on("data", c => (raw += c)); + req.on("end", async () => { + const url = new URL(req.url, "http://x"); + const path = decodeURIComponent(url.pathname).replace("/_matrix/client/v3", ""); + const bearer = (req.headers.authorization ?? "").replace("Bearer ", ""); + const reply = (status, body) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; + const record = { method: req.method, path, body: raw ? JSON.parse(raw) : null, who: users[bearer], query: url.searchParams }; + seen.push(record); + if (path === "/account/whoami") return reply(200, { user_id: users[bearer] }); + if (path === "/joined_rooms") return reply(200, { joined_rooms: [ROOM] }); + if (path.includes("/account_data/m.direct")) return reply(404, { errcode: "M_NOT_FOUND", error: "Account data not found" }); + if (path.startsWith("/directory/room/")) return reply(200, { room_id: ROOM, servers: [HS] }); + if (req.method === "GET" && path === `/rooms/${ROOM}/messages`) { + if (headHangs) return; // never answers + await new Promise(r => setTimeout(r, delay("get"))); + if (headStatus !== 200) return reply(headStatus, { errcode: "M_UNKNOWN", error: "Internal server error" }); + const types = JSON.parse(url.searchParams.get("filter") ?? "{}").types; + const page = room.filter(e => !types || types.includes(e.type)).reverse().slice(0, Number(url.searchParams.get("limit") ?? 10)); + return reply(200, { start: "s1", chunk: page, end: "e1" }); + } + const put = path.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/); + if (req.method === "PUT" && put) { + await new Promise(r => setTimeout(r, delay("put"))); + if (!sent.has(put[2])) { + sent.set(put[2], `$sent-${sent.size + 1}`); + room.push({ type: "m.room.message", event_id: sent.get(put[2]), sender: users[bearer], origin_server_ts: t0 + 1000 + sent.size, content: record.body }); + } + return reply(200, { event_id: sent.get(put[2]) }); + } + 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}`, room, + puts: () => seen.filter(r => r.method === "PUT" && r.path.includes("/send/")), + heads: () => seen.filter(r => r.method === "GET" && r.path.endsWith("/messages")), + 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 mouth = (hs, persona, token, extra = {}) => createMouth({ + persona, homeserver: hs.url, serverName: HS, rooms: [ALIAS], token, state: memoryState(), brake: async () => true, syncTimeoutMs: 1, ...extra, +}); +const send = (m, requestId, basis, persona = "eureka") => m.send({ requestId, persona, roomAlias: ALIAS, text: `line ${requestId}`, basis }); +const basisOn = (event, sinceUnixMs = 0) => ({ seenEventId: event.event_id, sinceUnixMs }); + +test("a stale send makes no PUT and answers RoomMoved with the newer ids, oldest first", async () => { + const hs = await homeserver({ users: { tok: EUREKA } }); + try { + const receipt = await send(mouth(hs, "eureka", "tok"), "r1", basisOn(question)); + assert.equal(receipt.refusal, "RoomMoved"); + assert.equal(receipt.eventId, null); + // Every message after the question except her own three lines. + assert.deepEqual(receipt.newer, idsBy(swarm.slice(1), e => e.sender !== EUREKA)); + assert.equal(receipt.newer.length, 7); + assert.equal(hs.puts().length, 0, "a refused send reaches the room never"); + const [head] = hs.heads(); + assert.deepEqual([head.query.get("dir"), head.query.get("limit"), JSON.parse(head.query.get("filter"))], ["b", "50", { types: ["m.room.message"] }], "the head read asks for messages only, newest first"); + } finally { await hs.close(); } +}); + +test("a send whose room moved only by m.notice, m.replace or her own account goes out", async () => { + const quiet = [question, notice, edit, { ...swarm[6], event_id: "$her-own" }]; + const hs = await homeserver({ events: quiet, users: { tok: EUREKA } }); + try { + const receipt = await send(mouth(hs, "eureka", "tok"), "r2", basisOn(question)); + assert.deepEqual([receipt.refusal, receipt.newer], [null, []]); + assert.match(receipt.eventId, /^\$sent-/); + assert.equal(hs.puts().length, 1); + } finally { await hs.close(); } +}); + +test("a threaded reply moves the room", async () => { + const hs = await homeserver({ events: [question, threaded], users: { tok: EUREKA } }); + try { + const receipt = await send(mouth(hs, "eureka", "tok"), "r3", basisOn(question)); + assert.deepEqual([receipt.refusal, receipt.newer], ["RoomMoved", [threaded.event_id]]); + } finally { await hs.close(); } +}); + +test("movesRoom over the captured #swarm chunk and the synthetic kinds", () => { + const moved = swarm.filter(e => movesRoom(e, EUREKA)).map(e => e.event_id); + assert.deepEqual(moved, idsBy(swarm, e => e.sender !== EUREKA), "the eleven real events count except her three lines"); + assert.equal(moved.length, 8); + assert.equal(movesRoom(threaded, EUREKA), true, "a threaded reply counts"); + assert.equal(movesRoom(edit, EUREKA), false, "an m.replace edit does not"); + assert.equal(movesRoom(notice, EUREKA), false, "an m.notice does not"); + assert.equal(movesRoom({ type: "m.room.member", sender: `@x:${HS}`, content: { membership: "join" } }, EUREKA), false, "only messages move a room"); +}); + +test("no seen event: the since floor decides; a seen event outside the page: the whole page counts", async () => { + const hs = await homeserver({ users: { tok: EUREKA } }); + try { + const m = mouth(hs, "eureka", "tok"); + const libby = swarm[5]; + const floor = await send(m, "r4", { seenEventId: null, sinceUnixMs: libby.origin_server_ts }); + assert.deepEqual(floor.newer, idsBy(swarm.slice(6), e => e.sender !== EUREKA), "only what came after the since time; the equal-time event is her turn's own start"); + const lost = await send(m, "r5", { seenEventId: "$never-in-the-page", sinceUnixMs: t0 + 1e6 }); + assert.deepEqual(lost.newer, idsBy(swarm, e => e.sender !== EUREKA), "the unreachable seen event leaves the whole page, whatever the since time says"); + const seenWins = await send(m, "r6", { seenEventId: swarm[9].event_id, sinceUnixMs: 0 }); + assert.deepEqual(seenWins.newer, [swarm[10].event_id], "with a seen event, the since time does not matter"); + } finally { await hs.close(); } +}); + +test("a failed head read makes no PUT and answers SendFailed; so does a head read that hangs", async () => { + for (const mode of [{ headStatus: 500 }, { headHangs: true }]) { + const hs = await homeserver({ users: { tok: EUREKA }, ...mode }); + const logs = []; + try { + const receipt = await send(mouth(hs, "eureka", "tok", { sendTimeoutMs: 80, log: l => logs.push(l) }), "r7", basisOn(question)); + assert.deepEqual([receipt.eventId, receipt.refusal, receipt.newer], [null, "SendFailed", []], JSON.stringify(mode)); + assert.equal(hs.puts().length, 0, "the guarantee does not fail open"); + } finally { await hs.close(); } + } +}); + +test("a failed send does not stop the next one in that room", async () => { + const gate = createRoomGate(); + await assert.rejects(gate.run(ROOM, async () => { throw new Error("boom"); }), /boom/); + assert.equal(await gate.run(ROOM, async () => "next"), "next"); +}); + +test("eight Minds answering one message: one PUT, seven RoomMoved naming the first", async () => { + const names = Array.from({ length: 8 }, (_, i) => `mind${i}`); + const users = Object.fromEntries(names.map(n => [`tok-${n}`, `@${n}:${HS}`])); + const hs = await homeserver({ events: [question], users, delay: () => 25 }); + try { + const gate = createRoomGate(); + const receipts = await Promise.all(names.map(n => send(mouth(hs, n, `tok-${n}`, { gate }), `req-${n}`, basisOn(question), n))); + assert.equal(hs.puts().length, 1, "exactly one Mind spoke"); + const winner = receipts.filter(r => r.refusal === null); + assert.equal(winner.length, 1); + const refused = receipts.filter(r => r.refusal !== null); + assert.equal(refused.length, 7); + for (const r of refused) assert.deepEqual([r.refusal, r.newer], ["RoomMoved", [winner[0].eventId]]); + } finally { await hs.close(); } +}); + +test("revisions with a nil basis all go out, in the order they reached the gate, and read no head", async () => { + const hs = await homeserver({ events: [question], users: { tok: EUREKA }, delay: kind => (kind === "put" ? 15 : 0) }); + try { + const inner = createRoomGate(); + let arrivals = 0; + const started = []; + const gate = { run: (room, fn) => { const n = arrivals++; return inner.run(room, () => { started.push(n); return fn(); }); } }; + const m = mouth(hs, "eureka", "tok", { gate }); + const ids = Array.from({ length: 7 }, (_, i) => `rev-${i}`); + const receipts = await Promise.all(ids.map(id => send(m, id, null))); + assert.ok(receipts.every(r => r.refusal === null)); + assert.deepEqual(started, [0, 1, 2, 3, 4, 5, 6], "the gate runs them in the order they reached it"); + assert.deepEqual(hs.puts().map(p => p.body.body).sort(), ids.map(id => `line ${id}`), "all seven went out"); + assert.equal(hs.heads().length, 0, "her choice stands: no check"); + } finally { await hs.close(); } +}); + +test("a v1 send (no basis field at all) is served unchecked through the gate", async () => { + const hs = await homeserver({ users: { tok: EUREKA } }); + try { + const receipt = await mouth(hs, "eureka", "tok").send({ requestId: "v1", persona: "eureka", roomAlias: ALIAS, text: "old organ" }); + assert.equal(receipt.refusal, null); + assert.equal(hs.heads().length, 0); + assert.equal(hs.puts().length, 1); + } finally { await hs.close(); } +}); + +test("a gated send is serial with a gated send in the same room and leaves other rooms alone", async () => { + const gate = createRoomGate(); + const order = []; + const hold = (room, name, ms) => gate.run(room, async () => { order.push(`${name}:start`); await new Promise(r => setTimeout(r, ms)); order.push(`${name}:end`); }); + await Promise.all([hold("!a", "a1", 30), hold("!a", "a2", 1), hold("!b", "b1", 1)]); + assert.deepEqual(order, ["a1:start", "b1:start", "b1:end", "a1:end", "a2:start", "a2:end"]); +}); + +test("a send queued at the gate re-checks the brake before its PUT: braked during the wait, no PUT and a typed Brake refusal", async () => { + const hs = await homeserver({ users: { tok: EUREKA }, delay: kind => (kind === "put" ? 150 : 0) }); + try { + let released = true; + const m = mouth(hs, "eureka", "tok", { brake: async () => released }); + const first = send(m, "g1"); // holds the room gate for the length of its PUT + await new Promise(r => setTimeout(r, 50)); + const queued = send(m, "g2"); // passes the entry brake check, then waits at the gate + await new Promise(r => setTimeout(r, 20)); + released = false; + const [a, b] = await Promise.all([first, queued]); + assert.equal(a.refusal, null); + assert.deepEqual([b.refusal, b.eventId, b.newer], ["Brake", null, []]); + assert.deepEqual(hs.puts().map(p => p.body.body), ["line g1"], "the queued send never reaches the room"); + } finally { await hs.close(); } +}); diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs new file mode 100644 index 0000000..958b684 --- /dev/null +++ b/tests/persona-matrix-mouth.test.mjs @@ -0,0 +1,2132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { existsSync, mkdtempSync, statSync as stat } from "node:fs"; +import { tmpdir } from "node:os"; +import { createHash } from "node:crypto"; +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, 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"; +const ROOMS = { [`#swarm:${HS_NAME}`]: "!swarm:gamecult.test", [`#operator:${HS_NAME}`]: "!operator:gamecult.test" }; +const SELF = `@eureka:${HS_NAME}`; + +const message = (id, sender, body, ts) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", body } }); +// The one write a round makes by design: her presence state (any state; the brake and the grace decide which). +const offlinePresence = r => r.method === "PUT" && r.path.includes("/presence/"); +const tick = () => new Promise(r => setImmediate(r)); + +// A fake Matrix client-server API: just the endpoints the mouth uses. +// It is also the record of every room: events the sync script emits and messages sent are kept, and +// `preload` (room id -> events, oldest first) stands for history from before the mouth's process began. +async function fakeHomeserver({ sync, failWith, users, member, direct, preload = {}, failContext = false, media, failReceipts = false, deferReceipts = null, failPresence = 0 } = {}) { + const seen = []; + const record_ = new Map(Object.entries(preload).map(([room, events]) => [room, [...events]])); + const remember = (room, event) => { const list = record_.get(room) ?? []; if (!list.some(e => e.event_id === event.event_id)) list.push(event); record_.set(room, list); }; + const sent = new Map(); // txnId -> event_id + // The homeserver is answered in-process (no socket): the suite runs under a capped address space, where undici's HTTP client cannot start. + const handler = (req, res) => { + let raw = ""; + req.on("data", c => (raw += c)); + req.on("end", () => { + const url = new URL(req.url, "http://x"); + const record = { method: req.method, rawPath: url.pathname, path: decodeURIComponent(url.pathname), query: url.searchParams, auth: req.headers.authorization, body: raw ? JSON.parse(raw) : null }; + seen.push(record); + const reply = (status, body) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; + if (failWith) return reply(failWith, { errcode: "M_UNKNOWN_TOKEN", error: `bad token ${record.auth}` }); + const p = record.path.replace("/_matrix/client/v3", "").replace("/_matrix/client/v1", ""); + const bearer = (record.auth ?? "").replace("Bearer ", ""); + if (req.method === "POST" && p.includes("/receipt/m.read/")) { + if (deferReceipts) return deferReceipts.then(() => reply(200, {})); + return failReceipts ? reply(500, { errcode: "M_UNKNOWN", error: `down ${record.auth}` }) : reply(200, {}); + } + if (req.method === "PUT" && p.includes("/presence/")) return failPresence-- > 0 ? reply(500, { errcode: "M_UNKNOWN" }) : reply(200, {}); + if (p.startsWith("/media/download/")) { + const served = media?.(bearer, ...p.slice("/media/download/".length).split("/")) ?? { status: 404, body: "" }; + res.writeHead(served.status ?? 200, served.headers ?? {}); + return res.end(served.body); + } + if (p === "/account/whoami") return reply(200, { user_id: users?.[bearer] ?? SELF }); + if (p === "/joined_rooms") return reply(200, { joined_rooms: member ?? Object.values(ROOMS) }); + if (req.method === "PUT" && p.includes("/account_data/m.direct")) return reply(200, {}); + if (p.endsWith("/leave")) return reply(200, {}); + 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: ROOMS[p.slice("/directory/room/".length)] }); + if (p.startsWith("/join/")) return reply(200, { room_id: p.slice(6) }); + if (p === "/sync") { + const out = sync(record.query.get("since"), bearer); + for (const [room, r] of Object.entries(out.rooms?.join ?? {})) for (const e of r.timeline?.events ?? []) remember(room, e); + return reply(200, out); + } + const ctx = p.match(/^\/rooms\/(.+?)\/(messages|event\/.+|relations\/.+)$/); + if (ctx && req.method === "GET") { + if (failContext) return reply(500, { errcode: "M_UNKNOWN", error: `down ${record.auth}` }); + const events = record_.get(ctx[1]) ?? []; + const limit = Number(record.query.get("limit") ?? 10); + const threadOf = e => (e.content?.["m.relates_to"]?.rel_type === "m.thread" ? e.content["m.relates_to"].event_id : null); + const newestFirst = list => list.slice().reverse().slice(0, limit); + if (ctx[2] === "messages") return reply(200, { chunk: newestFirst(events) }); + if (ctx[2].startsWith("event/")) { const e = events.find(x => x.event_id === ctx[2].slice(6)); return e ? reply(200, e) : reply(404, { errcode: "M_NOT_FOUND" }); } + const root = ctx[2].match(/^relations\/(.+)\/m\.thread$/)?.[1]; + return reply(200, { chunk: newestFirst(events.filter(e => threadOf(e) === root)) }); + } + const m = p.match(/^\/rooms\/(.+)\/send\/m\.room\.message\/(.+)$/); + if (m && req.method === "PUT") { + if (!sent.has(m[2])) { sent.set(m[2], `$event${sent.size + 1}`); remember(m[1], { type: "m.room.message", event_id: sent.get(m[2]), sender: users?.[bearer] ?? SELF, origin_server_ts: 5000 + sent.size, content: record.body }); } + return reply(200, { event_id: sent.get(m[2]) }); + } + reply(404, { errcode: "M_NOT_FOUND" }); + }); + }; + const fetch = (url, init = {}) => new Promise((resolve, reject) => { + if (init.signal?.aborted) return reject(init.signal.reason); + init.signal?.addEventListener("abort", () => reject(init.signal.reason), { once: true }); + const u = new URL(url); + const req = Object.assign(new EventEmitter(), { method: init.method ?? "GET", url: u.pathname + u.search, headers: init.headers ?? {} }); + const res = { + writeHead(status, headers) { this.status = status; this.headers = headers; }, + end(body) { resolve(new Response(body === "" ? null : body, { status: this.status, headers: this.headers })); }, + }; + handler(req, res); + process.nextTick(() => { if (init.body !== undefined) req.emit("data", init.body); req.emit("end"); }); + }); + return { + url: "http://hs.test", + fetch, + seen, + puts: () => seen.filter(r => r.method === "PUT" && !offlinePresence(r)), + receipts: () => seen.filter(r => r.path.includes("/receipt/")), + presencePuts: () => seen.filter(r => r.path.includes("/presence/")), + downloads: () => seen.filter(r => r.path.includes("/media/download/")), + close: async () => {}, + }; +} + +function memoryState(initial = null) { + const box = { value: initial }; + return { box, load: async () => box.value, save: async v => { box.value = v; } }; +} + +const joined = events => ({ rooms: { join: events } }); +function mouthFor(hs, overrides = {}) { + return 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, ...overrides, + }); +} + +// The scripted timeline: the sync with no position is the baseline; later positions advance it. +const timeline = { + null: { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$old", `@alice:${HS_NAME}`, "history before the ear", 1)] } } }) }, + b0: { + next_batch: "b1", + ...joined({ + "!swarm:gamecult.test": { + state: { events: [{ type: "m.room.member", state_key: `@alice:${HS_NAME}`, content: { displayname: "Alice" } }] }, + timeline: { events: [ + message("$m1", `@alice:${HS_NAME}`, "hello eureka", 1000), + message("$m2", SELF, "my own words", 1001), + { type: "m.room.topic", event_id: "$t", sender: `@alice:${HS_NAME}`, content: { topic: "x" } }, + ] }, + }, + "!elsewhere:gamecult.test": { timeline: { events: [message("$x", `@bob:${HS_NAME}`, "not my room", 1002)] } }, + }), + }, + b1: { next_batch: "b2", ...joined({ "!operator:gamecult.test": { timeline: { events: [message("$m3", `@bob:${HS_NAME}`, "second batch", 2000)] } } }) }, +}; +const script = since => timeline[since] ?? { next_batch: since }; + +test("sync_message_becomes_delivery: another sender's message in a joined room is one delivery with context; own and foreign-room messages are none", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + assert.deepEqual(await mouth.syncOnce(), [], "history at first sync is context, not mail"); + const made = await mouth.syncOnce(); + assert.equal(made.length, 1); + const [d] = made; + assert.deepEqual({ roomAlias: d.roomAlias, roomId: d.roomId, eventId: d.eventId, sender: d.sender, senderDisplay: d.senderDisplay, text: d.text, atUnixMs: d.atUnixMs }, { + roomAlias: `#swarm:${HS_NAME}`, roomId: "!swarm:gamecult.test", eventId: "$m1", sender: `@alice:${HS_NAME}`, senderDisplay: "Alice", text: "hello eureka", atUnixMs: 1000, + }); + assert.deepEqual(d.context, [{ sender: `@alice:${HS_NAME}`, text: "history before the ear", atUnixMs: 1 }]); + assert.equal(d.seq, 1); + assert.deepEqual(mouth.pendingAfter(0).map(x => x.eventId), ["$m1"]); + assert.deepEqual(mouth.pendingAfter(1), []); + } finally { await hs.close(); } +}); + +test("context holds the last 20 messages before the delivery, oldest first", async () => { + const many = Array.from({ length: 25 }, (_, i) => message(`$h${i}`, `@alice:${HS_NAME}`, `m${i}`, 10 + i)); + const hs = await fakeHomeserver({ + sync: since => since === null + ? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: many } } }) } + : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$n", `@bob:${HS_NAME}`, "now", 99)] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const [d] = await mouth.syncOnce(); + assert.equal(d.context.length, 20); + assert.equal(d.context[0].text, "m5"); + assert.equal(d.context[19].text, "m24"); + } finally { await hs.close(); } +}); + +test("next_batch_persisted_only_after_ack: restart before ack re-delivers; after ack does not", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const state = memoryState(); + const first = mouthFor(hs, { state }); + await first.syncOnce(); // baseline b0, nothing pending: persisted at once + assert.equal(state.box.value.nextBatch, "b0"); + const [d] = await first.syncOnce(); // delivery in the batch ending b1 + assert.equal(state.box.value.nextBatch, "b0", "b1 must not be persisted before an ack"); + + const restarted = mouthFor(hs, { state }); // crash before the ack + const [again] = await restarted.syncOnce(); + assert.equal(again.eventId, "$m1", "the unacked message arrives again"); + assert.ok(again.seq > d.seq, "sequence numbers never repeat across a restart"); + + await restarted.ack(again.seq); + assert.equal(state.box.value.nextBatch, "b1"); + + const afterAck = mouthFor(hs, { state }); + assert.deepEqual((await afterAck.syncOnce()).map(x => x.eventId), ["$m3"], "an acked batch is not delivered again: only the newer message arrives"); + assert.equal(hs.seen.filter(r => r.path.endsWith("/sync")).at(-1).query.get("since"), "b1"); + } finally { await hs.close(); } +}); + +test("an ack covers only the batches whose deliveries it reaches", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const state = memoryState(); + const mouth = mouthFor(hs, { state }); + await mouth.syncOnce(); + const [a] = await mouth.syncOnce(); // batch ending b1 holds seq 1 + const [b] = await mouth.syncOnce(); // batch ending b2 holds seq 2 + assert.deepEqual([a.seq, b.seq], [1, 2]); + await mouth.ack(1); + assert.equal(state.box.value.nextBatch, "b1", "acking seq 1 persists the batch that ends at seq 1, not the later one"); + await mouth.ack(2); + assert.equal(state.box.value.nextBatch, "b2"); + assert.equal(mouth.pendingCount, 0); + } finally { await hs.close(); } +}); + +test("send_uses_request_id_txn: the PUT carries txnId = request_id and a repeat sends the same txnId", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + const request = { requestId: "req 7f", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hello world" }; + const one = await mouth.send(request); + const two = await mouth.send(request); + assert.equal(one.refusal, null); + assert.equal(one.eventId, two.eventId, "the homeserver deduplicates by txnId, so a retry is the same event"); + const puts = hs.puts(); + assert.equal(puts.length, 2); + for (const put of puts) { + assert.equal(put.path, "/_matrix/client/v3/rooms/!swarm:gamecult.test/send/m.room.message/req 7f"); + assert.deepEqual(put.body, { msgtype: "m.text", body: "hello world" }); + } + assert.notEqual((await mouth.send({ ...request, requestId: "req-8" })).eventId, one.eventId, "a different request is a different txn"); + } finally { await hs.close(); } +}); + +test("send refuses another Persona's request, an unknown room and empty text, without a PUT", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + assert.equal((await mouth.send({ requestId: "a", persona: "other", roomAlias: `#swarm:${HS_NAME}`, text: "x" })).refusal, "PersonaMismatch"); + assert.equal((await mouth.send({ requestId: "b", persona: "eureka", roomAlias: "#nowhere:gamecult.test", text: "x" })).refusal, "UnknownRoom"); + assert.equal((await mouth.send({ requestId: "c", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: " " })).refusal, "EmptyText"); + assert.equal(hs.puts().length, 0); + } finally { await hs.close(); } +}); + +const payload = value => Buffer.from(JSON.stringify(value)); +const store = (...envelopes) => ({ absent: false, envelopes }); +const brakeEnvelope = value => ({ type: "eureka.control.brake.v1", key: "brake", payload: payload(value) }); +const jsonDecode = b => JSON.parse(Buffer.from(b).toString()); +const readerFor = read => createBrakeReader({ controlPath: "x", readStore: () => read, decode: jsonDecode }); + +test("held_brake_refuses_send: held, throwing, missing and non-boolean brakes refuse with Brake and nothing reaches the homeserver", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const request = { requestId: "r1", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "speak" }; + const brakes = [ + async () => false, + async () => { throw new Error("control store unreadable"); }, + readerFor({ absent: true, envelopes: [] }), + readerFor(store(brakeEnvelope([false, "t", "op"]))), + async () => "yes", + ]; + for (const brake of brakes) { + const receipt = await mouthFor(hs, { brake }).send(request); + assert.deepEqual({ eventId: receipt.eventId, refusal: receipt.refusal }, { eventId: null, refusal: "Brake" }); + } + assert.equal(hs.puts().length, 0); + assert.equal(hs.seen.length, 0, "a refused send does not even touch the homeserver"); + const released = await mouthFor(hs, { brake: readerFor(store(brakeEnvelope([true, "t", "op"]))) }).send(request); + assert.equal(released.refusal, null, "the same request goes through once the brake is released"); + } finally { await hs.close(); } +}); + +test("brake reader: only a decoded released brake counts as released", async () => { + assert.equal(await readerFor(store(brakeEnvelope([true, "t", "op"])))(), true); + assert.equal(await readerFor(store(brakeEnvelope([false, "t", "op"])))(), false); + assert.equal(await readerFor(store(brakeEnvelope({ 0: true })))(), true, "integer-keyed map form"); + assert.equal(await readerFor(store(brakeEnvelope({ 0: false })))(), false); + assert.equal(await readerFor(store(brakeEnvelope(["true"])))(), false, "a non-boolean is not a release"); + assert.equal(await readerFor(store())(), false, "no brake document"); + assert.equal(await readerFor(store({ ...brakeEnvelope([true]), key: "dial" }))(), false, "wrong key"); + assert.equal(await readerFor(store({ ...brakeEnvelope([true]), type: "other.v1" }))(), false, "wrong type"); + assert.equal(await createBrakeReader({ controlPath: "x", readStore: () => { throw new Error("corrupt"); }, decode: jsonDecode })(), false); + assert.equal(await readerFor(store({ type: "eureka.control.brake.v1", key: "brake", payload: Buffer.from("not json") }))(), false, "undecodable bytes"); +}); + +test("the Matrix token goes only in the Authorization header: never in logs, errors, deliveries or receipts", async () => { + const logs = []; + const bad = await fakeHomeserver({ sync: script, failWith: 401 }); + try { + const mouth = mouthFor(bad, { log: l => logs.push(l) }); + await assert.rejects(mouth.syncOnce(), err => { assert.ok(!err.message.includes(TOKEN)); return true; }); + const receipt = await mouth.send({ requestId: "r", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "x" }).catch(e => ({ thrown: e.message })); + assert.ok(!JSON.stringify(receipt).includes(TOKEN)); + const controller = new AbortController(); + const running = mouthFor(bad, { log: l => logs.push(l) }).run(controller.signal); + await new Promise(r => setTimeout(r, 30)); + controller.abort(); + void running; // the run loop is left to its backoff; only its log lines matter here + } finally { await bad.close(); } + + const unreachable = mouthFor({ url: "http://127.0.0.1:1" }, { + log: l => logs.push(l), + fetch: async (url, init) => { throw new Error(`connect failed with ${init.headers.authorization} to ${url}`); }, + }); + await assert.rejects(unreachable.syncOnce(), err => { assert.ok(!err.message.includes(TOKEN), "transport errors are redacted"); return true; }); + + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs, { log: l => logs.push(l) }); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + const receipt = await mouth.send({ requestId: "ok", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hi" }); + assert.ok(!JSON.stringify([made, receipt, mouth.pendingAfter(0)]).includes(TOKEN)); + assert.ok(hs.seen.every(r => r.auth === `Bearer ${TOKEN}`), "the token is presented to the homeserver on every call"); + } finally { await hs.close(); } + + assert.ok(logs.length > 0, "failures were logged"); + assert.ok(logs.every(l => !l.includes(TOKEN)), `a log line carried the token: ${logs.find(l => l.includes(TOKEN))}`); + assert.equal(redact(`x ${TOKEN} y`, TOKEN), "x [redacted] y"); + assert.equal(redact("a1 b2 a1", ["a1", "b2"]), "[redacted] [redacted] [redacted]"); +}); + +test("a homeserver error is reported with its status and none of its body", async () => { + const hs = await fakeHomeserver({ sync: script, failWith: 500 }); + try { + await assert.rejects(mouthFor(hs).syncOnce(), err => /answered 500/.test(err.message) && !/bad token/.test(err.message)); + } finally { await hs.close(); } +}); + +function walk(dir, out = []) { + for (const name of readdirSync(dir)) { + if (name === ".git" || name === "node_modules" || name === "bin" || name === "obj") continue; + const full = join(dir, name); + statSync(full).isDirectory() ? walk(full, out) : out.push(full); + } + return out; +} + +test("the Discord posting actuator is gone and the Matrix path names no Discord", () => { + const root = resolve(import.meta.dirname, ".."); + for (const file of ["tools/persona-matrix-mouth.mjs", "tools/persona-chat-documents.mjs"]) { + assert.ok(!/discord/i.test(readFileSync(resolve(root, file), "utf8")), `${file} mentions Discord`); + } + const survivors = walk(root).filter(f => /\.(mjs|cs|json|ya?ml|ps1|sh)$/.test(f) && /persona-discord-delivery/.test(readFileSync(f, "utf8")) && !/persona-matrix-mouth\.test\.mjs$/.test(f)); + assert.deepEqual(survivors, []); + for (const gone of ["persona-discord-delivery.mjs", "persona-discord-rudp.mjs", "persona-discord-delivery-documents.mjs", "persona-discord-permit.mjs"]) { + assert.throws(() => statSync(resolve(root, "tools", gone))); + } +}); + +// ---- one mouth, many Personas ---------------------------------------------------- + +const NOVA = `@nova:${HS_NAME}`; +const TOKEN_NOVA = "syt_OTHER_TOKEN_77x"; + +test("two_personas_two_queues: one message in a room both joined yields one delivery per Persona with separate seqs and only its own token", async () => { + const both = since => since === null + ? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [] } } }) } + : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$m1", `@alice:${HS_NAME}`, "hello both", 1000)] } } }) }; + const hs = await fakeHomeserver({ sync: both, users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA } }); + try { + const eureka = mouthFor(hs); + const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA }); + await eureka.syncOnce(); await nova.syncOnce(); + await eureka.syncOnce(); + await eureka.syncOnce(); // a second delivery for eureka only + await nova.syncOnce(); + assert.deepEqual(eureka.pendingAfter(0).map(d => d.seq), [1, 2]); + assert.deepEqual(nova.pendingAfter(0).map(d => d.seq), [1], "separate queue, separate seq"); + await eureka.ack(2); + assert.equal(eureka.pendingCount, 0); + assert.equal(nova.pendingCount, 1, "one Persona's ack leaves the other's mail alone"); + for (const r of hs.seen) assert.ok([`Bearer ${TOKEN}`, `Bearer ${TOKEN_NOVA}`].includes(r.auth)); + const novaCalls = hs.seen.filter(r => r.auth === `Bearer ${TOKEN_NOVA}`); + assert.ok(novaCalls.length >= 3 && novaCalls.every(r => !JSON.stringify(r).includes(TOKEN)), "a token never appears in another account's request"); + } finally { await hs.close(); } +}); + +test("a Persona's own words are not mail for it, but are for the other Persona", async () => { + const hs = await fakeHomeserver({ + users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA }, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$e", SELF, "eureka speaking", 5)] } } }) }, + }); + try { + const eureka = mouthFor(hs); + const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA }); + for (const m of [eureka, nova]) { await m.syncOnce(); await m.syncOnce(); } + assert.equal(eureka.pendingCount, 0); + assert.deepEqual(nova.pendingAfter(0).map(d => d.sender), [SELF]); + } finally { await hs.close(); } +}); + +test("mentions_carried: m.mentions user ids and the exact sender id are on the delivery; absent or malformed mentions are empty", async () => { + const withMentions = (id, mentions) => ({ ...message(id, `@meta:${HS_NAME}`, "hi", 7), content: { msgtype: "m.text", body: "hi", ...(mentions === undefined ? {} : { "m.mentions": mentions }) } }); + const hs = await fakeHomeserver({ + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [ + withMentions("$a", { user_ids: [SELF, NOVA] }), withMentions("$b", undefined), withMentions("$c", { user_ids: "nope" }), withMentions("$d", { room: true }), + ] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + assert.deepEqual(made.map(d => d.mentions), [[SELF, NOVA], [], [], []]); + assert.ok(made.every(d => d.sender === `@meta:${HS_NAME}`)); + assert.ok(made.every(d => d.isDirect === false)); + } finally { await hs.close(); } +}); + +test("dm_flag: a joined room the account marks in m.direct is delivered as a DM, answerable by its room id; other rooms are not", async () => { + const DM = "!dm:gamecult.test"; + const hs = await fakeHomeserver({ + direct: { [`@meta:${HS_NAME}`]: [DM] }, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", account_data: { events: [{ type: "m.direct", content: { [`@meta:${HS_NAME}`]: [DM, "!dm2:gamecult.test"] } }] }, ...joined({ + [DM]: { timeline: { events: [message("$dm", `@meta:${HS_NAME}`, "private", 9)] } }, + "!dm2:gamecult.test": { timeline: { events: [message("$dm2", `@meta:${HS_NAME}`, "second", 10)] } }, + "!swarm:gamecult.test": { timeline: { events: [message("$pub", `@meta:${HS_NAME}`, "public", 11)] } }, + }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + assert.deepEqual(made.map(d => [d.eventId, d.roomAlias, d.isDirect]), [["$dm", DM, true], ["$dm2", "!dm2:gamecult.test", true], ["$pub", `#swarm:${HS_NAME}`, false]]); + assert.equal((await mouth.send({ requestId: "dm-1", persona: "eureka", roomAlias: DM, text: "reply" })).refusal, null); + assert.equal(hs.puts().at(-1).path, `/_matrix/client/v3/rooms/${DM}/send/m.room.message/dm-1`); + } finally { await hs.close(); } +}); + +test("pending_is_not_ack: repeated reads return the same deliveries until an ack", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); await mouth.syncOnce(); + const first = mouth.pendingAfter(0), second = mouth.pendingAfter(0); + assert.deepEqual(first, second); + assert.equal(first.length, 1); + await mouth.ack(first[0].seq); + assert.deepEqual(mouth.pendingAfter(0), []); + } finally { await hs.close(); } +}); + +test("join_while_braked: no join and no send while the brake is held; joining happens on the round after release; a failing join is logged, redacted, and never throws", async () => { + let open = false; + const logs = []; + const hs = await fakeHomeserver({ sync: script, member: [] }); + try { + const mouth = mouthFor(hs, { brake: async () => open, log: l => logs.push(l) }); + await mouth.syncOnce(); + await mouth.send({ requestId: "r", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "speak" }); + assert.equal(hs.seen.filter(r => (r.method === "POST" || r.method === "PUT") && !offlinePresence(r)).length, 0, "braked: nothing is joined or sent"); + open = true; + await mouth.syncOnce(); + assert.deepEqual(hs.seen.filter(r => r.method === "POST").map(r => r.path.split("/join/")[1]).sort(), ["!operator:gamecult.test", "!swarm:gamecult.test"]); + await mouth.syncOnce(); + assert.equal(hs.seen.filter(r => r.method === "POST").length, 2, "joined rooms are not joined again"); + } finally { await hs.close(); } + + const failing = await fakeHomeserver({ sync: script, member: [] }); + try { + const mouth = mouthFor(failing, { fetch: (url, init) => String(url).includes("/join/") ? Promise.reject(new Error(`join refused for ${init.headers.authorization}`)) : failing.fetch(url, init), log: l => logs.push(l) }); + await mouth.syncOnce(); // no throw: the unit does not crash-loop on a failed join + assert.ok(logs.some(l => l.includes("not joined")) && logs.every(l => !l.includes(TOKEN))); + } finally { await failing.close(); } +}); + +test("a freshly joined room's history is context, not mail; an already joined room's backlog still is mail after a restart", async () => { + const hs = await fakeHomeserver({ + member: ["!operator:gamecult.test"], + sync: since => since === "b0" + ? { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$hist", `@alice:${HS_NAME}`, "before I joined", 1)] } }, "!operator:gamecult.test": { timeline: { events: [message("$mail", `@alice:${HS_NAME}`, "waiting for me", 2)] } } }) } + : { next_batch: "b0" }, + }); + try { + const mouth = mouthFor(hs, { state: memoryState({ nextBatch: "b0", seq: 4 }) }); + const made = await mouth.syncOnce(); + assert.deepEqual(made.map(d => d.eventId), ["$mail"]); + assert.equal(made[0].seq, 5); + } finally { await hs.close(); } +}); + +test("pending is bounded: at the bound the mouth stops reading, keeps its position, and resumes after an ack", async () => { + const hs = await fakeHomeserver({ sync: since => ({ next_batch: `n${since}`, ...joined({ "!swarm:gamecult.test": { timeline: { events: [message(`$e${since}`, `@alice:${HS_NAME}`, "again", 5)] } } }) }) }); + try { + const mouth = mouthFor(hs, { maxPending: 2 }); + await mouth.syncOnce(); // baseline + await mouth.syncOnce(); await mouth.syncOnce(); + const syncs = () => hs.seen.filter(r => r.path.endsWith("/sync")).length; + const before = syncs(); + assert.deepEqual(await mouth.syncOnce(), []); + assert.equal(syncs(), before, "no /sync while the mailbox is at the bound"); + assert.equal(mouth.pendingCount, 2); + await mouth.ack(2); + assert.equal((await mouth.syncOnce()).length, 1); + } finally { await hs.close(); } +}); + +test("guarded: a failure leaves with every secret redacted, however many tokens the process holds", async () => { + await assert.rejects(guarded([TOKEN, TOKEN_NOVA], async () => { throw new Error(`boom ${TOKEN} then ${TOKEN_NOVA}`); }), err => err.message === "boom [redacted] then [redacted]"); + assert.equal(await guarded([TOKEN], async () => 5), 5); +}); + +test("the ring is gone: the mouth names no unit manager and spawns no child", () => { + const source = readFileSync(resolve(import.meta.dirname, "..", "tools", "persona-matrix-mouth.mjs"), "utf8"); + assert.ok(!/systemctl|child_process|execFile|mind-persona@/.test(source)); +}); + +test("delivery_poison_pill: a non-string display name or malformed event fields never reach a delivery; the sender falls back to its localpart", async () => { + const hs = await fakeHomeserver({ + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { + state: { events: [{ type: "m.room.member", state_key: `@alice:${HS_NAME}`, content: { displayname: 123 } }] }, + timeline: { events: [ + message("$a", `@alice:${HS_NAME}`, "numeric name", "not-a-number"), + { type: "m.room.message", event_id: "$b", sender: 5, content: { msgtype: "m.text", body: "numeric sender" } }, + { type: "m.room.message", sender: `@alice:${HS_NAME}`, content: { msgtype: "m.text", body: "no event id" } }, + ] }, + } }) }, + }); + try { + const mouth = mouthFor(hs, { now: () => 42 }); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + assert.deepEqual(made.map(d => [d.eventId, d.senderDisplay, d.atUnixMs]), [["$a", "alice", 42]]); + } finally { await hs.close(); } +}); + +test("delivery size: body, context lines and mentions are bounded per delivery, on a character boundary", async () => { + const big = "é".repeat(30000); + const hs = await fakeHomeserver({ + sync: since => since === null ? { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [message("$h", `@alice:${HS_NAME}`, big, 1)] } } }) } + : { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [{ ...message("$n", `@bob:${HS_NAME}`, big, 2), content: { msgtype: "m.text", body: big, "m.mentions": { user_ids: Array.from({ length: 500 }, (_, i) => `@u${i}:x`) } } }] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const [d] = await mouth.syncOnce(); + assert.ok(Buffer.byteLength(d.text) <= 4000 && d.text === "é".repeat(2000)); + assert.ok(d.context.every(c => Buffer.byteLength(c.text) <= 1000)); + assert.equal(d.mentions.length, 64); + } finally { await hs.close(); } +}); + +test("bound_is_a_hard_cap: a round that would pass the cap enqueues only what fits, keeps its position, and the rest arrives once after an ack", async () => { + const four = ["$w", "$x", "$y", "$z"].map((id, i) => message(id, `@alice:${HS_NAME}`, id, 10 + i)); + const state = memoryState(); + const hs = await fakeHomeserver({ + sync: since => since === null ? { next_batch: "b0" } : since === "b0" ? { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: four } } }) } : { next_batch: since }, + }); + try { + const mouth = mouthFor(hs, { maxPending: 3, state }); + await mouth.syncOnce(); + assert.deepEqual((await mouth.syncOnce()).map(d => d.eventId), ["$w", "$x", "$y"]); + assert.equal(mouth.pendingCount, 3, "never above the cap"); + assert.deepEqual(await mouth.syncOnce(), [], "full: no read"); + await mouth.ack(2); + const rest = await mouth.syncOnce(); + assert.deepEqual(rest.map(d => d.eventId), ["$z"], "the four-message round is read again; the three already taken are not delivered twice"); + assert.deepEqual(mouth.pendingAfter(0).map(d => d.eventId), ["$y", "$z"]); + assert.equal(state.box.value.nextBatch, "b0", "the position moves only once the whole round is acked"); + await mouth.ack(4); + assert.equal(state.box.value.nextBatch, "b1"); + } finally { await hs.close(); } +}); + +// ---- DM invitations ---------------------------------------------------------------- + +const DMROOM = "!newdm:gamecult.test"; +const invite = (inviter, isDirect = true) => ({ [DMROOM]: { invite_state: { events: [{ type: "m.room.member", state_key: SELF, sender: inviter, content: { membership: "invite", is_direct: isDirect } }] } } }); +const inviteSync = (inviter, isDirect) => since => since === null ? { next_batch: "b0" } + : since === "b0" ? { next_batch: "b1", rooms: { invite: invite(inviter, isDirect) } } + : since === "b1" ? { next_batch: "b2", ...joined({ [DMROOM]: { timeline: { events: [message("$first", `@meta:${HS_NAME}`, "hello in private", 3)] } } }) } + : { next_batch: since }; +const joinsOf = hs => hs.seen.filter(r => r.method === "POST" && r.path.includes("/join/")).map(r => r.path.split("/join/")[1]); +const leavesOf = hs => hs.seen.filter(r => r.method === "POST" && r.path.endsWith("/leave")); + +test("dm_invite_accepted: a same-server DM invitation is joined, remembered in m.direct, and its messages arrive as is_direct deliveries", async () => { + const hs = await fakeHomeserver({ sync: inviteSync(`@meta:${HS_NAME}`) }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual(joinsOf(hs), [DMROOM]); + const saved = hs.puts().find(r => r.path.endsWith("/account_data/m.direct")); + assert.deepEqual(saved.body, { [`@meta:${HS_NAME}`]: [DMROOM] }); + const [d] = await mouth.syncOnce(); + assert.deepEqual([d.eventId, d.roomAlias, d.isDirect, d.sender], ["$first", DMROOM, true, `@meta:${HS_NAME}`], "the message sent before the join is mail, not history"); + assert.equal((await mouth.send({ requestId: "dm", persona: "eureka", roomAlias: DMROOM, text: "hi" })).refusal, null); + } finally { await hs.close(); } +}); + +test("dm_invite_waits_for_release: an invitation received while braked is not joined until the brake is released", async () => { + let open = false; + const hs = await fakeHomeserver({ sync: inviteSync(`@meta:${HS_NAME}`) }); + try { + const mouth = mouthFor(hs, { brake: async () => open }); + await mouth.syncOnce(); await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual(hs.seen.filter(r => (r.method === "POST" || r.method === "PUT") && !offlinePresence(r)), [], "braked: no join, no leave, nothing written"); + open = true; + await mouth.syncOnce(); + assert.deepEqual(joinsOf(hs), [DMROOM]); + } finally { await hs.close(); } +}); + +test("dm_invite_other_server_declined: an invitation from another server is declined, never joined, brake or no brake", async () => { + const hs = await fakeHomeserver({ sync: inviteSync("@mallory:evil.example") }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual(joinsOf(hs), []); + assert.deepEqual(leavesOf(hs).map(r => r.path), [`/_matrix/client/v3/rooms/${DMROOM}/leave`]); + await mouth.syncOnce(); + assert.equal(leavesOf(hs).length, 1, "answered once"); + } finally { await hs.close(); } + const lookalike = await fakeHomeserver({ sync: inviteSync(`@mallory:${HS_NAME}.evil.example`) }); + try { + const mouth = mouthFor(lookalike); + await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual(joinsOf(lookalike), [], "the server name must match exactly"); + } finally { await lookalike.close(); } +}); + +test("a same-server invitation to a room that is not a DM is neither joined nor declined", async () => { + const hs = await fakeHomeserver({ sync: inviteSync(`@meta:${HS_NAME}`, false) }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual([joinsOf(hs), leavesOf(hs)], [[], []]); + } finally { await hs.close(); } +}); + +test("project rooms: slug mapping, per-Persona lists, absent variable, malformed alias", () => { + assert.equal(projectRoomsVariable("eureka"), "MATRIX_ROOMS_EUREKA"); + assert.equal(projectRoomsVariable("nova-2.x"), "MATRIX_ROOMS_NOVA_2_X"); + const env = { MATRIX_ROOMS: "#swarm:t, #ops:t", MATRIX_ROOMS_EUREKA: "#eureka-proj:t,#ops:t", MATRIX_ROOMS_NOVA_2: "#nova-proj:t" }; + assert.deepEqual(roomsFor(env, "eureka"), ["#swarm:t", "#ops:t", "#eureka-proj:t"], "shared plus own, deduplicated"); + assert.deepEqual(roomsFor(env, "nova-2"), ["#swarm:t", "#ops:t", "#nova-proj:t"], "dash maps to underscore"); + assert.ok(!roomsFor(env, "nova-2").includes("#eureka-proj:t") && !roomsFor(env, "eureka").includes("#nova-proj:t")); + assert.deepEqual(roomsFor(env, "other"), ["#swarm:t", "#ops:t"], "absent variable: shared rooms only"); + assert.throws(() => roomsFor({ ...env, MATRIX_ROOMS_EUREKA: "#ok:t,nope" }, "eureka"), /MATRIX_ROOMS_EUREKA.*malformed/); +}); + +test("send to another Persona's project room is refused as UnknownRoom", async () => { + const hs = await fakeHomeserver({ sync: script }); + try { + const env = { MATRIX_ROOMS: `#swarm:${HS_NAME}`, MATRIX_ROOMS_EUREKA: `#eureka-proj:${HS_NAME}`, MATRIX_ROOMS_NOVA: `#nova-proj:${HS_NAME}` }; + const mouth = mouthFor(hs, { rooms: roomsFor(env, "eureka") }); + assert.equal((await mouth.send({ requestId: "p", persona: "eureka", roomAlias: `#nova-proj:${HS_NAME}`, text: "x" })).refusal, "UnknownRoom"); + assert.equal(hs.puts().length, 0); + } finally { await hs.close(); } +}); + +// Context is read from the homeserver, not remembered: these probes start the mouth with a +// room history the process never saw. +const ROOM = "!swarm:gamecult.test"; +const DM = "!dm:gamecult.test"; +const ALICE = `@alice:${HS_NAME}`; +const onlyAfterCursor = (roomId, events) => since => since === "c0" ? { next_batch: "c1", ...joined({ [roomId]: { timeline: { events } } }) } : { next_batch: since }; +const persistedCursor = () => memoryState({ nextBatch: "c0", seq: 7 }); + +test("context_survives_restart: a mouth that never saw the room delivers with the exchange read back from the homeserver", async () => { + const past = [message("$p1", ALICE, "first", 10), message("$p2", SELF, "my answer", 11), message("$p3", ALICE, "second", 12)]; + const hs = await fakeHomeserver({ preload: { [ROOM]: past }, sync: onlyAfterCursor(ROOM, [message("$n", ALICE, "now", 20)]) }); + try { + const [d] = await mouthFor(hs, { state: persistedCursor() }).syncOnce(); + assert.deepEqual(d.context.map(c => [c.sender, c.text]), [[ALICE, "first"], [SELF, "my answer"], [ALICE, "second"]]); + const asked = hs.seen.find(r => r.path.endsWith("/messages")); + assert.equal(asked.query.get("dir"), "b"); + assert.equal(asked.auth, `Bearer ${TOKEN}`); + } finally { await hs.close(); } +}); + +test("dm_context_holds_the_whole_exchange: her own earlier replies are in it, and it ends before the delivered message", async () => { + const past = [message("$d1", ALICE, "hi", 1), message("$d2", SELF, "hello, Deru", 2), message("$d3", ALICE, "do you remember", 3)]; + const hs = await fakeHomeserver({ preload: { [DM]: past }, direct: { [ALICE]: [DM] }, member: [...Object.values(ROOMS), DM], + sync: onlyAfterCursor(DM, [message("$d4", ALICE, "the thing", 4), message("$d5", ALICE, "after it", 5)]) }); + try { + const made = await mouthFor(hs, { state: persistedCursor() }).syncOnce(); + assert.equal(made.length, 2); + assert.ok(made[0].isDirect); + assert.deepEqual(made[0].context.map(c => c.text), ["hi", "hello, Deru", "do you remember"]); + assert.deepEqual(made[1].context.map(c => c.text), ["hi", "hello, Deru", "do you remember", "the thing"]); + assert.equal(hs.seen.filter(r => r.path.endsWith("/messages")).length, 1, "one fetch serves every delivery of the room in a round"); + } finally { await hs.close(); } +}); + +test("thread_context_is_the_thread: root and replies before the delivered reply, not the room's other messages", async () => { + const thread = { rel_type: "m.thread", event_id: "$root" }; + const inThread = (id, sender, body, ts) => ({ ...message(id, sender, body, ts), content: { msgtype: "m.text", body, "m.relates_to": thread } }); + const past = [message("$root", ALICE, "thread root", 1), message("$x", ALICE, "elsewhere in the room", 2), inThread("$t1", ALICE, "in thread", 3), inThread("$t2", SELF, "my thread reply", 4)]; + const hs = await fakeHomeserver({ preload: { [ROOM]: past }, sync: onlyAfterCursor(ROOM, [inThread("$t3", ALICE, "latest", 5)]) }); + try { + const [d] = await mouthFor(hs, { state: persistedCursor() }).syncOnce(); + assert.deepEqual(d.context.map(c => c.text), ["thread root", "in thread", "my thread reply"]); + assert.ok(hs.seen.some(r => r.path.includes("/relations/$root/m.thread"))); + assert.ok(!hs.seen.some(r => r.path.endsWith("/messages"))); + } finally { await hs.close(); } +}); + +test("context_byte_budget: the newest lines that fit 16 KiB, each clipped to a context line, at most 20", async () => { + const wide = Array.from({ length: 40 }, (_, i) => message(`$w${i}`, ALICE, `${String(i).padStart(2, "0")}${"é".repeat(2000)}`, 10 + i)); + const hs = await fakeHomeserver({ preload: { [ROOM]: wide }, sync: onlyAfterCursor(ROOM, [message("$n", ALICE, "now", 99)]) }); + try { + const [d] = await mouthFor(hs, { state: persistedCursor() }).syncOnce(); + const bytes = d.context.reduce((n, c) => n + Buffer.byteLength(c.text) + Buffer.byteLength(c.sender), 0); + assert.ok(bytes <= 16 * 1024, `${bytes} bytes`); + assert.ok(d.context.every(c => Buffer.byteLength(c.text) <= 1000)); + assert.ok(d.context.length > 0 && d.context.length < 20, "bytes, not count, bound it"); + assert.ok(d.context.at(-1).text.startsWith("39"), "the newest line is kept"); + assert.ok(d.context.at(0).text.startsWith(String(40 - d.context.length)), "the lines are contiguous to the newest"); + const short = Array.from({ length: 40 }, (_, i) => message(`$s${i}`, ALICE, `s${i}`, 10 + i)); + const hs2 = await fakeHomeserver({ preload: { [ROOM]: short }, sync: onlyAfterCursor(ROOM, [message("$n", ALICE, "now", 99)]) }); + try { assert.equal((await mouthFor(hs2, { state: persistedCursor() }).syncOnce())[0].context.length, 20, "the wire carries 20 lines"); } finally { await hs2.close(); } + } finally { await hs.close(); } +}); + +test("context_fetch_failure: empty context, one logged line without the token, and the delivery still goes out", async () => { + const lines = []; + const hs = await fakeHomeserver({ failContext: true, preload: { [ROOM]: [message("$p", ALICE, "past", 1)] }, sync: onlyAfterCursor(ROOM, [message("$n", ALICE, "now", 9)]) }); + try { + const [d] = await mouthFor(hs, { state: persistedCursor(), log: l => lines.push(l) }).syncOnce(); + assert.equal(d.text, "now"); + assert.deepEqual(d.context, []); + const logged = lines.filter(l => l.includes("context for")); + assert.equal(logged.length, 1); + assert.ok(!logged[0].includes(TOKEN)); + } finally { await hs.close(); } +}); + +const media = (msgtype, content) => ({ msgtype, ...content }); +const MXC_URL = "mxc://gamecult.test/AbC_123-x"; + +test("media_line: an image, file, audio or video is one typed line naming kind, filename, mime, size and mxc url", () => { + const info = { mimetype: "image/png", size: 2048, w: 10, h: 10 }; + assert.equal(bodyText(media("m.image", { body: "cat.png", url: MXC_URL, info }), 4096), `[image filename="cat.png" mime=image/png size=2048 url=${MXC_URL}]`); + assert.equal(bodyText(media("m.file", { body: "notes.pdf", url: MXC_URL, info: { mimetype: "application/pdf", size: 0 } }), 4096), `[file filename="notes.pdf" mime=application/pdf size=0 url=${MXC_URL}]`); + assert.match(bodyText(media("m.audio", { body: "a.ogg", url: MXC_URL }), 4096), /^\[audio filename="a\.ogg" url=mxc:/); + assert.match(bodyText(media("m.video", { body: "v.mp4", url: MXC_URL }), 4096), /^\[video filename="v\.mp4" url=mxc:/); +}); + +test("media_line: a caption follows the typed line only when the event names its file apart from the body", () => { + const withCaption = bodyText(media("m.image", { body: "look at this", filename: "cat.png", url: MXC_URL }), 4096); + assert.equal(withCaption, `[image filename="cat.png" url=${MXC_URL}]\nlook at this`); + assert.equal(bodyText(media("m.image", { body: "cat.png", filename: "cat.png", url: MXC_URL }), 4096), `[image filename="cat.png" url=${MXC_URL}]`); +}); + +test("media_line: text, notice and emote bodies are untouched", () => { + for (const msgtype of ["m.text", "m.notice", "m.emote", undefined]) assert.equal(bodyText({ msgtype, body: "plain words" }, 4096), "plain words"); +}); + +test("media_line: a hostile field is quoted or left out, never printed raw; an encrypted attachment names its file url and never its key", () => { + const line = bodyText(media("m.file", { body: 'a"]\n[image filename="x', url: "https://evil.example/x", info: { mimetype: "text/html\nx", size: -1 } }), 4096); + assert.equal(line, '[file filename="a\\"]\\n[image filename=\\"x"]'); + assert.equal(line.includes("\n"), false); + const sealed = bodyText(media("m.image", { body: "s.png", file: { url: MXC_URL, key: { k: "SECRET_KEY_MATERIAL" }, iv: "IV", hashes: { sha256: "H" } }, info: { mimetype: "image/png" } }), 4096); + assert.equal(sealed, `[image filename="s.png" mime=image/png url=${MXC_URL} encrypted]`); + assert.equal(sealed.includes("SECRET_KEY_MATERIAL"), false); +}); + +test("media_line: the line is bounded to the byte cap on a character boundary", () => { + const long = bodyText(media("m.file", { body: "é".repeat(400), url: MXC_URL }), 100); + assert.ok(Buffer.byteLength(long) <= 100); + assert.equal(long.includes("�"), false); +}); + +test("media_delivery: a Matrix image reaches the delivery and a later delivery's context as the typed line, with no media fetch and no token in it", async () => { + const img = { type: "m.room.message", event_id: "$img", sender: `@alice:${HS_NAME}`, origin_server_ts: 1000, content: media("m.image", { body: "cat.png", url: MXC_URL, info: { mimetype: "image/png", size: 2048 } }) }; + const after = message("$after", `@alice:${HS_NAME}`, "did you see it", 2000); + const tl = { + null: { next_batch: "b0", ...joined({ "!swarm:gamecult.test": { timeline: { events: [] } } }) }, + b0: { next_batch: "b1", ...joined({ "!swarm:gamecult.test": { timeline: { events: [img] } } }) }, + b1: { next_batch: "b2", ...joined({ "!swarm:gamecult.test": { timeline: { events: [after] } } }) }, + }; + const hs = await fakeHomeserver({ sync: since => tl[since] ?? { next_batch: since } }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + const [d] = await mouth.syncOnce(); + const line = `[image filename="cat.png" mime=image/png size=2048 url=${MXC_URL}]`; + assert.equal(d.text, line); + const [next] = await mouth.syncOnce(); + assert.equal(next.context.at(-1).text, line); + assert.deepEqual(hs.seen.filter(r => r.path.includes("/media/") || r.path.includes("/download")), []); + assert.equal(JSON.stringify(mouth.pendingAfter(0)).includes(TOKEN), false); + } finally { await hs.close(); } +}); + +// ---- chat images ---- + +const IMG_ROOM = "!swarm:gamecult.test"; +const sha = data => createHash("sha256").update(data).digest("hex"); +const imageEvent = (id, content, extra = {}) => ({ type: "m.room.message", event_id: id, sender: `@alice:${HS_NAME}`, origin_server_ts: 1000, content: media("m.image", { body: "cat.png", url: MXC_URL, info: { mimetype: "image/png", size: 2048 }, ...content }), ...extra }); +const imageTimeline = (events, room = IMG_ROOM) => ({ + null: { next_batch: "b0", ...joined({ [IMG_ROOM]: { timeline: { events: [] } } }) }, + b0: { next_batch: "b1", ...joined({ [room]: { timeline: { events } } }) }, +}); +const served = bytes => (bearer, server, id) => (bearer === TOKEN && server === "gamecult.test" && id === "AbC_123-x" ? { status: 200, body: bytes } : { status: 401, body: "" }); +function imageSetup(clock = { t: Date.now() }) { + const root = personaStoreRoot(); + return { root, clock, images: createImageStore({ root, now: () => clock.t }) }; +} +async function receive(events, { media: mediaFn, room, images, mouthOptions = {} } = {}) { + const tl = imageTimeline(events, room); + const hs = await fakeHomeserver({ sync: since => tl[since] ?? { next_batch: since }, media: mediaFn }); + const mouth = mouthFor(hs, { images, ...mouthOptions }); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + return { hs, mouth, made }; +} + +test("image_delivery: an m.image event is fetched as this Persona, stored by hash, and the delivery carries the reference in the v3 tuple", async () => { + const bytes = png(40, 30); + const { root, images } = imageSetup(); + const { hs, made } = await receive([imageEvent("$img", {})], { media: served(bytes), images }); + try { + assert.equal(made.length, 1); + const [d] = made; + assert.deepEqual(d.image, { path: `${sha(IMG_ROOM)}/${sha(bytes)}.png`, mime: "image/png", size: bytes.length, sha256: sha(bytes) }); + assert.ok(existsSync(join(root, d.image.path))); + assert.deepEqual(hs.downloads().map(r => [r.method, r.path, r.auth]), [["GET", "/_matrix/client/v1/media/download/gamecult.test/AbC_123-x", `Bearer ${TOKEN}`]]); + assert.equal(d.text, `[image filename="cat.png" mime=image/png size=2048 url=${MXC_URL}]`, "the text line is unchanged when the image is stored"); + const tuple = docs.deliveryTuple(d); + assert.equal(tuple.length, 12); + assert.deepEqual(tuple[11], [d.image.path, "image/png", bytes.length, sha(bytes)]); + assert.ok(!d.image.path.startsWith("/") && !d.image.path.includes("..")); + assert.equal(stat(join(root, d.image.path)).mode & 0o777, 0o640); + assert.equal(stat(join(root, d.image.path, "..")).mode & 0o777, 0o750); + } finally { await hs.close(); } +}); + +test("image_delivery: the file name is ours, never the sender's filename, body or info", async () => { + const bytes = png(5, 5); + const { root, images } = imageSetup(); + const { hs, made } = await receive([imageEvent("$img", { body: "../../etc/passwd", filename: "/abs/evil.sh", info: { mimetype: "text/x-shellscript", size: 1 } })], { media: served(bytes), images }); + try { + assert.equal(made[0].image.path, `${sha(IMG_ROOM)}/${sha(bytes)}.png`); + assert.equal(made[0].image.mime, "image/png", "the sniffed type, not the declared one"); + assert.ok(!JSON.stringify(made[0].image).includes("evil")); + } finally { await hs.close(); } +}); + +test("image_delivery: only an event the Persona is delivered is fetched: not her own, not another room's, not history, not a plain message naming an mxc url", async () => { + const { images } = imageSetup(); + const events = [ + imageEvent("$own", {}, { sender: SELF }), + imageEvent("$x", {}), + { ...message("$text", `@alice:${HS_NAME}`, `see ${MXC_URL} and https://evil.example/a.png`, 1100), content: { msgtype: "m.text", body: `see ${MXC_URL}` } }, + ]; + const tl = { + null: { next_batch: "b0", ...joined({ [IMG_ROOM]: { timeline: { events: [imageEvent("$hist", {})] } } }) }, + b0: { next_batch: "b1", ...joined({ [IMG_ROOM]: { timeline: { events: [events[0], events[2]] } }, "!elsewhere:gamecult.test": { timeline: { events: [events[1]] } } }) }, + }; + const hs = await fakeHomeserver({ sync: since => tl[since] ?? { next_batch: since }, media: served(png()) }); + try { + const mouth = mouthFor(hs, { images }); + await mouth.syncOnce(); + const made = await mouth.syncOnce(); + assert.deepEqual(made.map(d => d.eventId), ["$text"]); + assert.equal(made[0].image, null); + assert.deepEqual(hs.downloads(), []); + } finally { await hs.close(); } +}); + +test("image_delivery: the url is the event's own content.url: a body or caption that names an mxc is not followed", async () => { + const { images } = imageSetup(); + const { hs, made } = await receive([imageEvent("$img", { url: undefined, body: MXC_URL })], { media: served(png()), images }); + try { + assert.equal(made[0].image, null); + assert.match(made[0].text, /refused=no-url\]/); + assert.deepEqual(hs.downloads(), []); + } finally { await hs.close(); } +}); + +test("image_delivery: an encrypted attachment is never fetched, whether or not it also names a url", async () => { + const { images } = imageSetup(); + const file = { url: MXC_URL, key: { k: "SECRET_KEY_MATERIAL" }, iv: "IV", hashes: { sha256: "H" } }; + const { hs, made } = await receive([imageEvent("$sealed", { url: undefined, file }), imageEvent("$both", { file }, { origin_server_ts: 1001 })], { media: served(png()), images }); + try { + assert.deepEqual(made.map(d => d.image), [null, null]); + assert.ok(made.every(d => d.text.includes("refused=encrypted") && !d.text.includes("SECRET_KEY_MATERIAL"))); + assert.deepEqual(hs.downloads(), []); + } finally { await hs.close(); } +}); + +test("image_delivery: a refused or failed fetch gives a nil image and today's line with a fixed-word reason", async () => { + const cases = [ + ["wrong-type", served(svg)], + ["fetch-failed", () => ({ status: 404, body: "" })], + ["too-big", (b, s, i) => ({ ...served(png())(b, s, i), headers: { "content-length": String(6 * 1024 * 1024) } })], + ["too-many-pixels", served(png(9000, 9000))], + ["bad-header", served(png(0, 0))], + ]; + for (const [reason, mediaFn] of cases) { + const { images } = imageSetup(); + const { hs, made } = await receive([imageEvent("$img", { filename: "evil\nrefused=fake" })], { media: mediaFn, images }); + try { + assert.equal(made[0].image, null, reason); + assert.equal(made[0].text.split("\n")[0].endsWith(`refused=${reason}]`), true, `${reason}: ${made[0].text}`); + assert.ok(made[0].text.startsWith("[image filename=")); + assert.equal(docs.deliveryTuple(made[0])[11], null); + } finally { await hs.close(); } + } +}); + +test("image_delivery: with no image store the line is today's and nothing is fetched", async () => { + const { hs, made } = await receive([imageEvent("$img", {})], { media: served(png()), images: null }); + try { + assert.equal(made[0].image, null); + assert.equal(made[0].text, `[image filename="cat.png" mime=image/png size=2048 url=${MXC_URL}]`); + assert.deepEqual(hs.downloads(), []); + } finally { await hs.close(); } +}); + +test("image_delivery: a stored image stays until its delivery is acked and it is 24 h old, and nothing but a reason word is logged", async () => { + const bytes = gif(3, 3); + const { root, clock, images } = imageSetup(); + const lines = []; + const { hs, mouth, made } = await receive([imageEvent("$img", {})], { media: served(bytes), images, mouthOptions: { log: l => lines.push(l) } }); + try { + const file = join(root, made[0].image.path); + clock.t += RETAIN_MS * 2; + images.sweep(); + assert.ok(existsSync(file), "a day old but unacked: kept"); + await mouth.ack(made[0].seq); + assert.ok(!existsSync(file), "acked and a day old: gone"); + assert.deepEqual(lines.filter(l => l.includes(MXC_URL) || l.includes(root) || l.includes(sha(bytes))), []); + } finally { await hs.close(); } +}); + +test("image_delivery: an acked image under 24 h stays", async () => { + const { root, clock, images } = imageSetup(); + const { hs, mouth, made } = await receive([imageEvent("$img", {})], { media: served(png()), images }); + try { + await mouth.ack(made[0].seq); + clock.t += RETAIN_MS - 5000; + images.sweep(); + assert.ok(existsSync(join(root, made[0].image.path))); + } finally { await hs.close(); } +}); + +test("image_delivery: a store that cannot write leaves the line and a nil image, and the sync goes on", async () => { + const dead = { put() { throw Object.assign(new Error("no space"), { code: "ENOSPC" }); }, hold() {}, acked() {} }; + const lines = []; + const { hs, made } = await receive([imageEvent("$img", {})], { media: served(png()), images: dead, mouthOptions: { log: l => lines.push(l) } }); + try { + assert.equal(made[0].image, null); + assert.match(made[0].text, /refused=store-failed\]/); + assert.ok(lines.some(l => l.includes("ENOSPC"))); + } finally { await hs.close(); } +}); + +// Read receipts and presence: Huginn's chat_read report posts her m.read; an ack posts nothing; her presence follows a +// turn and her brake. +const A = "!swarm:gamecult.test", B = "!operator:gamecult.test"; +const receiptScript = since => since === null ? { next_batch: "b0", ...joined({}) } : since === "b0" ? { + next_batch: "b1", + ...joined({ + [A]: { timeline: { events: [message("$a1", `@alice:${HS_NAME}`, "one", 10), message("$a2", `@alice:${HS_NAME}`, "two", 11)] } }, + [B]: { timeline: { events: [message("$b1", `@bob:${HS_NAME}`, "three", 12)] } }, + }), +} : { next_batch: since }; +const until = async check => { for (let i = 0; i < 200 && !check(); i++) await new Promise(r => setImmediate(r)); }; +const withMail = async (hs, overrides) => { + const mouth = mouthFor(hs, overrides); + await mouth.syncOnce(); + assert.equal((await mouth.syncOnce()).map(d => d.seq).join(), "1,2,3"); + return mouth; +}; +const receiptsOf = hs => hs.receipts().map(r => [r.path.split("/rooms/")[1].split("/receipt/")[0], r.path.split("/m.read/")[1], r.auth, r.body]); +const presencesOf = hs => hs.presencePuts().map(r => r.body.presence); +const syncsOf = hs => hs.seen.filter(r => r.path.endsWith("/sync")).map(r => r.query.get("set_presence")); +const reportRead = (mouth, roomAlias, eventId, persona = "eureka") => mouth.read({ persona, roomAlias, eventId, atUnixMs: 1 }); + +test("delivery, pending and an ack post no receipt: she has not read what the model has not seen", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + for (const after of [0, 1, 2, 3]) mouth.pendingAfter(after); + await mouth.ack(3); + await new Promise(r => setTimeout(r, 30)); + assert.deepEqual(hs.receipts(), [], "mail handed to her queue, polled and acked: still no m.read"); + assert.equal(mouth.pendingCount, 0); +}); + +test("a read report posts m.read at exactly that event id, as that Persona", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + assert.equal(await reportRead(mouth, A, "$a1"), null); + assert.deepEqual(receiptsOf(hs), [[A, "$a1", `Bearer ${TOKEN}`, {}]], "the reported event, not the newest in the room"); + assert.equal(await reportRead(mouth, B, "$b1"), null); + assert.deepEqual(receiptsOf(hs).slice(1), [[B, "$b1", `Bearer ${TOKEN}`, {}]]); + assert.equal(hs.receipts().length, 2); +}); + +test("a read report for an event the mouth never delivered, or in another room, posts nothing", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + assert.equal(await reportRead(mouth, A, "$made-up"), "UnknownEvent"); + assert.equal(await reportRead(mouth, B, "$a1"), "RoomMismatch"); + assert.deepEqual(hs.receipts(), []); +}); + +test("a read report never moves her receipt backward or repeats it in a room", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + assert.equal(await reportRead(mouth, A, "$a2"), null); + assert.equal(await reportRead(mouth, A, "$a1"), "Behind"); + assert.equal(await reportRead(mouth, A, "$a2"), "Behind"); + assert.equal(hs.receipts().length, 1); +}); + +test("a read report posts nothing while braked, and nothing is queued for the release", async () => { + let open = false; + const hs = await fakeHomeserver({ sync: receiptScript }); + const lines = []; + const mouth = await withMail(hs, { brake: async () => open, log: l => lines.push(l) }); + assert.equal(await reportRead(mouth, A, "$a1"), "Brake"); + assert.ok(lines.some(l => l.includes("receipt not posted: brake held"))); + open = true; + await mouth.syncOnce(); + await new Promise(r => setTimeout(r, 20)); + assert.deepEqual(hs.receipts(), []); + assert.equal(await reportRead(mouth, A, "$a1"), null, "a fresh report after the release posts"); +}); + +test("a read report names her persona: another persona's report posts nothing", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + assert.equal(await reportRead(mouth, A, "$a1", "someone-else"), "PersonaMismatch"); + assert.deepEqual(hs.receipts(), []); +}); + +test("the receipt goes to the delivered room id, not to the alias the report named", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + assert.equal(await reportRead(mouth, `#swarm:${HS_NAME}`, "$a1"), null); + assert.deepEqual(receiptsOf(hs), [[A, "$a1", `Bearer ${TOKEN}`, {}]]); +}); + +test("concurrent reports for one room are judged in arrival order: the newer lands and the older is behind", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs); + const [newer, older] = await Promise.all([reportRead(mouth, A, "$a2"), reportRead(mouth, A, "$a1")]); + assert.deepEqual([newer, older], [null, "Behind"]); + assert.deepEqual(receiptsOf(hs).map(r => r[1]), ["$a2"], "never a1 after a2"); + const [first, second] = await Promise.all([reportRead(mouth, B, "$b1"), reportRead(mouth, B, "$b1")]); + assert.deepEqual([first, second], [null, "Behind"], "the same event twice posts once"); +}); + +test("a failed receipt leaves the last landed receipt in place: an older event is still behind", async () => { + let receiptCalls = 0; + const hs = await fakeHomeserver({ sync: receiptScript }); + const fetch = (url, init) => String(url).includes("/receipt/") && ++receiptCalls === 2 ? Promise.resolve(new Response("{}", { status: 500 })) : hs.fetch(url, init); + const mouth = await withMail(hs, { fetch }); + assert.equal(await reportRead(mouth, A, "$a1"), null); + assert.equal(await reportRead(mouth, A, "$a2"), "Failed"); + assert.equal(await reportRead(mouth, A, "$a1"), "Behind", "a1 landed; the failure of a2 does not reopen the room to a backward post"); + assert.equal(await reportRead(mouth, A, "$a2"), null, "a2 itself can still land"); + assert.deepEqual(receiptsOf(hs).map(r => r[1]), ["$a1", "$a2"]); +}); + +test("a brake placed after the first brake read stops the receipt and no present state is PUT while braked", async () => { + let used = 0, allowed = Infinity; + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs, { brake: async () => used++ < allowed }); + used = 0; allowed = 1; // read() sees it released once; every read after that finds it held + assert.equal(await reportRead(mouth, A, "$a1"), "Brake"); + assert.deepEqual(hs.receipts(), [], "the receipt re-reads the brake after the presence write"); + assert.ok(!presencesOf(hs).includes("online"), "online is never PUT on a stale brake read"); + assert.equal(presencesOf(hs).at(-1), "offline"); +}); +test("a slow presence write after a send holds no other send in the room", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + const fetch = (url, init) => String(url).includes("/presence/") && init?.method === "PUT" + ? new Promise((_, reject) => init.signal.addEventListener("abort", () => reject(new Error("aborted")))) : hs.fetch(url, init); + const mouth = await withMail(hs, { fetch, sendTimeoutMs: 400 }); + const send = id => mouth.send({ requestId: id, persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: id }); + void send("s1"); // its presence write hangs + const second = await Promise.race([send("s2").then(r => r.refusal), new Promise(r => setTimeout(() => r("held behind the first send's presence write"), 150))]); + assert.equal(second, null); +}); + +test("the grace ends exactly at its boundary: one millisecond inside is online, the boundary is unavailable", async () => { + let t = 9_000_000; + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs, { now: () => t, presenceGraceMs: 60_000 }); + await mouth.ack(3); // touched at t + t += 59_999; + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online"); + t += 1; + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "unavailable"); +}); + +test("the state a poll carries is fixed before the poll: a grace that passes while it is open is PUT next round", async () => { + let t = 3_000_000; + const hs = await fakeHomeserver({ sync: receiptScript }); + const fetch = async (url, init) => { const res = await hs.fetch(url, init); if (String(url).includes("/sync")) t += 100_000; return res; }; + const mouth = mouthFor(hs, { now: () => t, presenceGraceMs: 60_000, fetch }); + await mouth.ack(0); // a turn: online + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online"); + await mouth.syncOnce(); + assert.deepEqual(presencesOf(hs), ["online", "unavailable"], "the homeserver was told online by the poll; the next round must tell it unavailable"); +}); + +test("a failed receipt is logged once, not retried, and the next report for the room posts", async () => { + const hs = await fakeHomeserver({ sync: receiptScript, failReceipts: true }); + const lines = []; + const mouth = await withMail(hs, { log: l => lines.push(l) }); + assert.equal(await reportRead(mouth, A, "$a1"), "Failed"); + const failures = lines.filter(l => l.startsWith("receipt in ")); + assert.equal(failures.length, 1); + assert.ok(failures[0].includes("not posted") && !failures[0].includes(TOKEN)); + await new Promise(r => setTimeout(r, 20)); + assert.equal(hs.receipts().length, 1, "no retry"); + assert.equal(await reportRead(mouth, A, "$a1"), "Failed", "the claim was released: the room is not stuck behind a failed post"); +}); + +test("a receipt that never answers is abandoned after sendTimeoutMs", async () => { + const hs = await fakeHomeserver({ sync: receiptScript, deferReceipts: new Promise(() => {}) }); + const lines = []; + const mouth = await withMail(hs, { sendTimeoutMs: 40, log: l => lines.push(l) }); + assert.equal(await reportRead(mouth, A, "$a1"), "Failed"); + assert.ok(lines.some(l => l.startsWith("receipt in ") && l.includes("not posted"))); + assert.equal(await reportRead(mouth, B, "$b1"), "Failed", "the next room's receipt went out after the first was abandoned"); + assert.equal(hs.receipts().length, 2); +}); + +test("a throwing logger on a failed receipt causes no unhandled rejection", async () => { + const hs = await fakeHomeserver({ sync: receiptScript, failReceipts: true }); + const mouth = await withMail(hs, { log: () => { throw new Error("logger down"); } }); + const rejected = []; + const note = e => rejected.push(e); + process.on("unhandledRejection", note); + try { + await reportRead(mouth, A, "$a1").catch(() => {}); + await new Promise(r => setTimeout(r, 50)); + } finally { process.off("unhandledRejection", note); } + assert.deepEqual(rejected, []); +}); + +test("the receipt path is percent-encoded on the wire", async () => { + const ROOM = "!od d/room:gamecult.test", EVENT = "$ev/with+odd:chars"; + const hs = await fakeHomeserver({ sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", account_data: { events: [{ type: "m.direct", content: { [`@meta:${HS_NAME}`]: [ROOM] } }] }, ...joined({ [ROOM]: { timeline: { events: [message(EVENT, `@meta:${HS_NAME}`, "x", 9)] } } }) } }); + const mouth = mouthFor(hs); + await mouth.syncOnce(); await mouth.syncOnce(); + assert.equal(await reportRead(mouth, ROOM, EVENT), null); + assert.equal(hs.receipts()[0].rawPath, `/_matrix/client/v3/rooms/${encodeURIComponent(ROOM)}/receipt/m.read/${encodeURIComponent(EVENT)}`); +}); + +test("presence: unavailable while released and idle, online through a turn, unavailable after the grace", async () => { + let t = 1_000_000; + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs, { now: () => t, presenceGraceMs: 60_000 }); + assert.deepEqual(syncsOf(hs), ["unavailable", "unavailable"], "mail alone does not make her present"); + assert.deepEqual(presencesOf(hs), ["unavailable"], "one PUT, not one per round"); + await reportRead(mouth, A, "$a1"); + assert.deepEqual(presencesOf(hs), ["unavailable", "online"], "the report puts her online at once, not at the next poll"); + t += 59_000; + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online", "inside the grace"); + t += 2_000; + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "unavailable", "past the grace"); + assert.deepEqual(presencesOf(hs), ["unavailable", "online", "unavailable"]); +}); + +test("a send or an ack within the grace keeps her online", async () => { + let t = 5_000_000; + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs, { now: () => t, presenceGraceMs: 60_000 }); + await mouth.ack(3); + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online", "an ack is a sign of a turn"); + t += 61_000; + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "unavailable"); + const sent = await mouth.send({ requestId: "p1", persona: "eureka", roomAlias: `#swarm:${HS_NAME}`, text: "hi" }); + assert.equal(sent.refusal, null); + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online", "a send is a sign of a turn"); +}); + +test("presence under the brake is offline whatever the turn: one PUT on the transition, and a report cannot lift it", async () => { + let open = true; + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = await withMail(hs, { brake: async () => open }); + await reportRead(mouth, A, "$a1"); + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online"); + open = false; + await mouth.syncOnce(); await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual(syncsOf(hs).slice(-3), ["offline", "offline", "offline"]); + assert.equal(presencesOf(hs).at(-1), "offline"); + assert.equal(presencesOf(hs).filter(p => p === "offline").length, 1, "exactly one offline PUT"); + assert.equal(hs.presencePuts().at(-1).rawPath, `/_matrix/client/v3/presence/${encodeURIComponent(SELF)}/status`, "the user id is percent-encoded on the wire"); + assert.equal(await reportRead(mouth, B, "$b1"), "Brake"); + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "offline"); + open = true; + await mouth.syncOnce(); + assert.equal(syncsOf(hs).at(-1), "online", "still inside the grace of the earlier report"); +}); + +test("the first braked round of a fresh mouth PUTs offline once; a failed PUT is tried again next round", async () => { + const hs = await fakeHomeserver({ sync: receiptScript, failPresence: 1 }); + const lines = []; + const mouth = mouthFor(hs, { brake: async () => false, log: l => lines.push(l) }); + await mouth.syncOnce(); + assert.equal(hs.presencePuts().length, 1); + assert.ok(lines.some(l => l.startsWith("presence not set")), "one log line"); + await mouth.syncOnce(); + assert.equal(hs.presencePuts().length, 2, "retried the next round"); + await mouth.syncOnce(); + assert.equal(hs.presencePuts().length, 2, "then settled"); +}); + +test("two Personas on one homeserver: receipts and presence carry only her own bearer and user id", async () => { + const ALICE = `@pa:${HS_NAME}`, BOB = `@pb:${HS_NAME}`; + const hs = await fakeHomeserver({ sync: receiptScript, users: { tokA: ALICE, tokB: BOB } }); + const a = await withMail(hs, { persona: "pa", token: "tokA" }); + let bOpen = false; + const b = await withMail(hs, { persona: "pb", token: "tokB", brake: async () => bOpen }); + await reportRead(a, A, "$a1", "pa"); + assert.equal(await reportRead(b, A, "$a1", "pb"), "Brake"); + assert.ok(hs.receipts().every(r => r.auth === "Bearer tokA"), "only A read"); + bOpen = true; // her receipts go out only while released + assert.equal(await reportRead(b, A, "$a1", "pb"), null); + assert.deepEqual([...new Set(hs.receipts().map(r => r.auth))].sort(), ["Bearer tokA", "Bearer tokB"]); + assert.ok(hs.presencePuts().some(r => r.path.endsWith(`/presence/${BOB}/status`) && r.auth === "Bearer tokB")); + assert.ok(hs.presencePuts().filter(r => r.path.endsWith(`/presence/${BOB}/status`)).every(r => r.auth === "Bearer tokB")); +}); + +// ---- review fixes ------------------------------------------------------------------ + +test("a brake placed during the long-poll stops the join the poll's invite asks for", async () => { + let open = true; + const script = inviteSync(`@meta:${HS_NAME}`); + const hs = await fakeHomeserver({ sync: since => { const out = script(since); if (since === "b0") open = false; return out; } }); // the brake lands while this poll is open + const mouth = mouthFor(hs, { brake: async () => open }); + await mouth.syncOnce(); // baseline, released + await mouth.syncOnce(); // the poll that carries the invite + assert.deepEqual(joinsOf(hs), [], "no join"); + assert.equal(hs.seen.filter(r => r.path.endsWith("/account_data/m.direct") && r.method === "PUT").length, 0, "no m.direct write"); +}); + +test("a full backlog still sets presence from the brake", async () => { + let open = true; + const hs = await fakeHomeserver({ sync: receiptScript }); + const mouth = mouthFor(hs, { brake: async () => open, maxPending: 3 }); + await mouth.syncOnce(); + assert.equal((await mouth.syncOnce()).length, 3); + const syncsBefore = hs.seen.filter(r => r.path.endsWith("/sync")).length; + open = false; + assert.deepEqual(await mouth.syncOnce(), [], "backlog full: no /sync"); + assert.equal(hs.seen.filter(r => r.path.endsWith("/sync")).length, syncsBefore); + assert.deepEqual(hs.presencePuts().map(r => r.body), [{ presence: "unavailable" }, { presence: "offline" }]); +}); + +test("a brake placed during the first of two joins stops the second join and its m.direct write", async () => { + const DM2 = "!newdm2:gamecult.test"; + const invites2 = { ...invite(`@meta:${HS_NAME}`), [DM2]: invite(`@meta:${HS_NAME}`)[DMROOM] }; + const hs = await fakeHomeserver({ sync: since => since === null ? { next_batch: "b0" } : since === "b0" ? { next_batch: "b1", rooms: { invite: invites2 } } : { next_batch: since } }); + let open = true; + const fetch = async (url, init) => { const res = await hs.fetch(url, init); if (init?.method === "POST" && String(url).includes("/join/")) open = false; return res; }; // the brake lands during the first join + const mouth = mouthFor(hs, { fetch, brake: async () => open }); + await mouth.syncOnce(); await mouth.syncOnce(); + assert.deepEqual(joinsOf(hs), [DMROOM], "only the join already started"); + assert.equal(hs.seen.filter(r => r.method === "PUT" && r.path.endsWith("/account_data/m.direct")).length, 1, "one m.direct, for the first join"); + open = true; + await mouth.syncOnce(); + assert.deepEqual(joinsOf(hs), [DMROOM, DM2], "the second waits for the release"); +}); + +test("chat_read round-trips and refuses a malformed tuple", () => { + const tuple = docs.readTuple({ persona: "eureka", roomAlias: "#swarm:gamecult.test", eventId: "$e1", atUnixMs: 12 }); + assert.deepEqual(tuple, ["eureka", "#swarm:gamecult.test", "$e1", 12]); + assert.deepEqual(docs.parseRead(tuple), { persona: "eureka", roomAlias: "#swarm:gamecult.test", eventId: "$e1", atUnixMs: 12 }); + assert.throws(() => docs.parseRead(["eureka", "#r", "", 1]), /event_id/); + assert.throws(() => docs.parseRead(["eureka", "#r", "$e", -1]), /at_unix_ms/); + assert.throws(() => docs.parseRead(["eureka", "#r", "$e"])); +}); + +test("a read report that throws does not wedge the room: the next report still posts and nothing is left unhandled", async () => { + const hs = await fakeHomeserver({ sync: receiptScript }); + let boom = false; + const fetch = (url, init) => boom && String(url).includes("/presence/") ? Promise.reject(new Error("net")) : hs.fetch(url, init); + const mouth = await withMail(hs, { fetch, log: line => { if (boom && line.startsWith("presence not set")) throw new Error("logger down"); } }); // the presence failure and a throwing logger make run() reject + const unhandled = [], note = error => unhandled.push(String(error)); + process.on("unhandledRejection", note); + try { + boom = true; + await assert.rejects(reportRead(mouth, A, "$a1"), /logger down/); + boom = false; + assert.equal(await reportRead(mouth, A, "$a2"), null, "the room's next report is judged and posted"); + await new Promise(resolve => setTimeout(resolve, 50)); + } finally { process.off("unhandledRejection", note); } + 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 new file mode 100644 index 0000000..954c051 --- /dev/null +++ b/tests/watchdog-notice.test.mjs @@ -0,0 +1,538 @@ +import { cultLibRoot } from "./cultlib-root.mjs"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, stat, writeFile, copyFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +// The fixture is the store Idunn's incident cut wrote: GameCult/Idunn branch +// hands/watchdog-idunn-incident, commit 7f7528b33e8e5c607c19cc7f8f8a8da4def50fca +// ("Add the idunn.operator_incident.v1 wire fixture"), tests/fixtures/ there. +// It holds two records: closed-target (closed, recovered) and open-target. +const FIXTURE = resolve(import.meta.dirname, "fixtures", "idunn.operator_incident.v1.cc"); +const FIXTURE_SHA256 = "f446770b07da99204e9036ef521f3136be6251e5b0a68e4e9533a89f25f18cab"; +const CLOSED_KEY = "continuity-exhausted:closed-target:1700000000000"; +const OPEN_KEY = "continuity-exhausted:open-target:1700000100000"; +const TOOL = resolve(import.meta.dirname, "..", "tools", "watchdog-notice.mjs"); +const BRIDGE = resolve(import.meta.dirname, "..", "tools", "bifrost-bridge.mjs"); +const RECIPIENT = "424242RECIPIENTCANARY"; + +const OPENED_TEXT = (subject, at) => `Idunn incident opened: continuity-exhausted on \`${subject}\` at ${at}`; +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}`; + +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"); + +// A bridge double that logs each call (with the content it was handed) and +// behaves as the `mode` file says: ok; fail (exit 75, retryable); die +// (exit 1 after "sending"); signal (killed after "sending"); hang (never answers after +// "sending"); flood (answers with more stdout than spawnSync buffers); or crash (kills its +// parent, the reader, after logging, as a power cut between send and journal would). +const FAKE_BRIDGE = ` +import { appendFileSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +const here = dirname(fileURLToPath(import.meta.url)); +const args = process.argv.slice(2); +const opt = (name) => args[args.indexOf("--" + name) + 1]; +const calls = resolve(here, "calls.jsonl"); +const n = (() => { try { return readFileSync(calls, "utf8").trim().split("\\n").length; } catch { return 0; } })(); +appendFileSync(calls, JSON.stringify({ args, content: readFileSync(opt("content-file"), "utf8") }) + "\\n"); +const mode = readFileSync(resolve(here, "mode"), "utf8").trim(); +if (mode === "crash") { process.kill(process.ppid, "SIGKILL"); process.exit(0); } +if (mode === "fail" || mode === "die") { process.stderr.write("boom " + opt("recipient-id")); process.stdout.write("boom " + opt("recipient-id")); process.exit(mode === "fail" ? 75 : 1); } +if (mode === "signal") process.kill(process.pid, "SIGKILL"); +if (mode === "hang") setTimeout(() => {}, 30000); +else if (mode === "flood") process.stdout.write("x".repeat(2 * 1024 * 1024)); +else +process.stdout.write(JSON.stringify({ action: "discord-dm", ok: true, messageId: "m" + (n + 1) })); +`; + +async function world(prefix, { incidents = FIXTURE, mode = "ok" } = {}) { + const dir = await mkdtemp(resolve(tmpdir(), prefix)); + const w = { + dir, + incidents: resolve(dir, "idunn", "incidents.cc"), + journal: resolve(dir, "bifrost", "journal.cc"), + receipts: resolve(dir, "bifrost", "receipts.cc"), + bridge: resolve(dir, "fake-bridge.mjs"), + calls: resolve(dir, "calls.jsonl"), + }; + await mkdir(resolve(dir, "idunn")); + await writeFile(w.bridge, FAKE_BRIDGE); + await writeFile(resolve(dir, "mode"), mode); + if (incidents) await copyFile(incidents, w.incidents); + w.setMode = (value) => writeFile(resolve(dir, "mode"), value); + w.run = (extra = {}) => run(w, extra); + w.status = (env) => runStatus(w, env); + w.posts = async () => (existsSync(w.calls) ? (await readFile(w.calls, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); + return w; +} + +// The reader reads Date.now(). A test drives the backoff the way it stubs fetch: +// a preload (fake-clock.mjs, passed to the reader as --import) fixes Date.now +// to the minute (from T0) the run happens at. The reader has no clock seam. +const T0 = 1_800_000_000_000; +const CLOCK = resolve(import.meta.dirname, "fake-clock.mjs"); +const at = (minutes) => ({ FAKE_NOW_MS: String(T0 + Math.round(minutes * 60_000)) }); + +function run(w, { env = {}, bridge = w.bridge } = {}) { + return spawnSync(process.execPath, ["--import", CLOCK, TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", bridge], { + encoding: "utf8", + env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT, ...env }, + }); +} + +function runStatus(w, env = {}) { + const result = spawnSync(process.execPath, ["--import", CLOCK, TOOL, "status", "--journal-store", w.journal], { encoding: "utf8", env: { ...process.env, ...env } }); + return { ...result, rows: result.status === null || !result.stdout ? [] : JSON.parse(result.stdout) }; +} + +const opt = (post, name) => post.args[post.args.indexOf(`--${name}`) + 1]; +const notice = (post) => opt(post, "cultmesh-command-id").split("#")[1]; + +// Builds an incident store from the fixture's own envelopes with the payload +// tuples rewritten by `edit`. +async function variantStore(path, edit) { + const source = await new cache.SingleFileMessagePackBackingStore(FIXTURE).pullAll(); + const tuples = edit(source.map((envelope) => msgpack.decode(envelope.payload))); + const envelopes = tuples.map((tuple, index) => ({ ...source[0], key: `variant-${index}-${tuple[1]}`, payload: msgpack.encode(tuple), schemaId: source[0].schemaId, catalogEntry: source[0].catalogEntry })); + await new cache.SingleFileMessagePackBackingStore(path).pushAll(envelopes); +} + +test("the Idunn fixture decodes as the incidents Idunn wrote", async () => { + const bytes = await readFile(FIXTURE); + assert.equal(createHash("sha256").update(bytes).digest("hex"), FIXTURE_SHA256); + const w = await world("wn-fixture-"); + const result = w.run(); + assert.equal(result.status, 0, result.stderr); + const posts = await w.posts(); + const byKey = Object.fromEntries(posts.map((post) => [opt(post, "cultmesh-command-id"), post.content])); + assert.deepEqual(byKey, { + [`${CLOSED_KEY}#opened`]: OPENED_TEXT("closed-target", T_OPEN), + [`${CLOSED_KEY}#closed`]: CLOSED_TEXT, + [`${OPEN_KEY}#opened`]: OPENED_TEXT("open-target", T_CLOSE), + }); +}); + +test("one incident yields one opening post across runs", async () => { + const w = await world("wn-once-"); + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + const posts = await w.posts(); + const openings = posts.filter((post) => notice(post) === "opened"); + assert.equal(openings.length, 2); + assert.deepEqual(openings.map((post) => opt(post, "source-id")).sort(), [CLOSED_KEY, OPEN_KEY]); + assert.equal(posts.length, 3, "no second run posts again"); + for (const post of posts) { + const key = opt(post, "cultmesh-command-id"); + assert.equal(opt(post, "recipient-id"), RECIPIENT); + assert.equal(opt(post, "nonce"), createHash("sha256").update(key).digest("hex").slice(0, 25)); + assert.equal(opt(post, "receipt-store"), w.receipts); + assert.equal(opt(post, "source-kind"), "idunn-operator-incident"); + } +}); + +test("closure is posted once and only after a completed opening", async () => { + const w = await world("wn-closure-"); + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + const closures = (await w.posts()).filter((post) => notice(post) === "closed"); + assert.equal(closures.length, 1); + assert.equal(opt(closures[0], "source-id"), CLOSED_KEY); + + // An opening that never completes blocks its closure, now and later. + const failing = await world("wn-closure-failed-", { mode: "fail" }); + for (const minute of [0, 1, 3, 7, 15]) failing.run({ env: at(minute) }); + assert.equal((await failing.posts()).filter((post) => notice(post) === "closed").length, 0); + const rows = (await failing.status()).rows; + assert.deepEqual(rows.map((row) => `${row.notice}:${row.status}:${row.attempts}`), ["opened:failed:5", "opened:failed:5"]); + await failing.setMode("ok"); + assert.equal(failing.run({ env: at(31) }).status, 0); + assert.equal(failing.run({ env: at(32) }).status, 0); + assert.deepEqual((await failing.posts()).filter((post) => notice(post) === "closed").map((post) => opt(post, "source-id")), [CLOSED_KEY], "the closure follows the completed opening, once"); + + const crashed = await world("wn-closure-unknown-", { mode: "crash" }); + assert.equal(crashed.run().status, null, "the reader died mid-send"); + await crashed.setMode("ok"); + crashed.run(); + crashed.run(); + assert.equal((await crashed.posts()).filter((post) => notice(post) === "closed").length, 0); +}); + +test("a running entry becomes unknown and is never re-posted", async () => { + const w = await world("wn-running-", { mode: "crash" }); + assert.equal(w.run().status, null); + assert.equal((await w.status()).rows[0].status, "running"); + await w.setMode("ok"); + const recovered = w.run(); + assert.notEqual(recovered.status, 0, "an unknown entry is a delivery failure"); + assert.equal(JSON.parse(recovered.stdout).unknown, 1); + const posts = await w.posts(); + assert.equal(posts.filter((post) => opt(post, "cultmesh-command-id") === `${CLOSED_KEY}#opened`).length, 1, "the interrupted notice is not sent again"); + const rows = (await w.status()).rows; + assert.equal(rows.find((row) => row.incidentKey === CLOSED_KEY && row.notice === "opened").status, "unknown"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0, "still failing, and not re-posted, long past any backoff"); + assert.equal((await w.posts()).length, posts.length); +}); + +test("an entry stamped ahead of the clock is eligible now, not deferred by the skew", async () => { + const w = await world("wn-skew-", { mode: "fail" }); + w.run({ env: at(1000) }); + assert.equal((await w.posts()).length, 2, "both openings tried once, stamped at minute 1000"); + w.run({ env: at(0) }); + assert.equal((await w.posts()).length, 4, "the clock is behind the stamp: retried at once"); + assert.equal((await w.status(at(0))).rows[0].nextEligibleAt, new Date(T0 + 2 * 60_000).toISOString(), "the retry rewrote the stamp from the corrected clock"); + w.run({ env: at(1.5) }); + assert.equal((await w.posts()).length, 4, "the cadence holds from the rewritten stamp"); + w.run({ env: at(2) }); + assert.equal((await w.posts()).length, 6); +}); + +test("the reader takes time from Date.now() alone, never from the environment", async () => { + const w = await world("wn-noseam-", { mode: "fail" }); + w.run({ env: { ...at(0), WATCHDOG_NOTICE_NOW_MS: "-5" } }); + w.run({ env: { ...at(0), WATCHDOG_NOTICE_NOW_MS: "8000000000000000" } }); + assert.equal((await w.posts()).length, 2, "no environment variable moves the clock"); + assert.equal((await w.status({ WATCHDOG_NOTICE_NOW_MS: "8000000000000000", ...at(0) })).rows[0].nextEligibleAt, new Date(T0 + 60_000).toISOString()); +}); + +const backoffMinutes = [0, 1, 3, 7, 15, 31, 63, 123, 183]; + +test("a failed notice is retried after its backoff and never exhausted", async () => { + const w = await world("wn-retry-", { mode: "fail" }); + // Run at every expected spawn minute and one minute before it: only the former may spawn. + const probes = [...new Set(backoffMinutes.flatMap((minute) => [minute - 1, minute]).filter((minute) => minute >= 0))].sort((a, b) => a - b); + const spawned = []; + for (const minute of probes) { + const before = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY).length; + w.run({ env: at(minute) }); + const after = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY).length; + if (after > before) spawned.push(minute); + } + assert.deepEqual(spawned, backoffMinutes, "attempts at 1, 2, 4, 8, 16, 32 then every 60 minutes after the last attempt"); + const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1, "every attempt carries the same nonce"); + const rows = (await w.status()).rows; + assert.deepEqual(rows.map((row) => `${row.status}:${row.attempts}:${row.lastError}`), ["failed:9:bridge-not-sent", "failed:9:bridge-not-sent"], "never a terminal state"); + const final = w.run({ env: at(183) }); + const status = await w.status(); + assert.notEqual(final.status, 0); + assert.notEqual(status.status, 0); + for (const text of [final.stdout, final.stderr, status.stdout, await readFile(w.journal, "latin1")]) { + assert.ok(!text.includes("RECIPIENTCANARY"), "bridge output and recipient never reach the reader's output or journal"); + } +}); + +test("a failed notice is not retried before its delay", async () => { + const w = await world("wn-delay-", { mode: "fail" }); + const count = async () => (await w.posts()).length; + w.run({ env: at(0) }); + assert.equal(await count(), 2, "both openings tried once"); + w.run({ env: at(0.99) }); + w.run({ env: at(0) }); + assert.equal(await count(), 2, "a run inside the delay spawns nothing"); + const row = (await w.status(at(0))).rows[0]; + assert.equal(row.attempts, 1); + assert.equal(row.nextEligibleAt, new Date(T0 + 60_000).toISOString(), "status names when the entry is next eligible"); + w.run({ env: at(1) }); + assert.equal(await count(), 4, "eligible once the delay has passed"); + assert.equal((await w.status(at(1))).rows[0].nextEligibleAt, new Date(T0 + 3 * 60_000).toISOString()); +}); + +test("the unit signals from the third failed attempt while still retrying", async () => { + const w = await world("wn-signal-", { mode: "fail" }); + const exits = []; + for (const minute of [0, 1, 3]) exits.push(w.run({ env: at(minute) }).status); + assert.deepEqual(exits, [0, 0, 1], "zero after attempts 1 and 2, non-zero from 3"); + assert.equal((await w.status()).status, 1); + const before = (await w.posts()).length; + assert.equal(w.run({ env: at(7) }).status, 1); + assert.equal((await w.posts()).length, before + 2, "attempt 4 still spawns"); + assert.ok((await w.status()).rows.every((row) => row.status === "failed" && row.attempts === 4)); +}); + +test("a notice that finally sends after many failures posts once", async () => { + const w = await world("wn-finally-", { mode: "fail" }); + for (const minute of [0, 1, 3, 7, 15]) w.run({ env: at(minute) }); + await w.setMode("ok"); + assert.equal(w.run({ env: at(31) }).status, 0); + const open = (await w.posts()).filter((post) => opt(post, "source-id") === OPEN_KEY); + assert.equal(open.length, 6, "five failures and one success"); + assert.equal(new Set(open.map((post) => opt(post, "nonce"))).size, 1, "the success carries the same nonce"); + const before = (await w.posts()).length; + for (const minute of [32, 100, 1000]) assert.equal(w.run({ env: at(minute) }).status, 0); + assert.equal((await w.posts()).length, before, "completed is never posted again"); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), ["completed:1", "completed:6", "completed:6"]); +}); + +test("the reader never writes or locks the incident store", async () => { + const w = await world("wn-readonly-"); + const before = { bytes: await readFile(w.incidents), mtime: (await stat(w.incidents)).mtimeMs }; + assert.equal(w.run().status, 0); + assert.equal(w.run().status, 0); + assert.deepEqual(await readFile(w.incidents), before.bytes); + assert.equal((await stat(w.incidents)).mtimeMs, before.mtime); + assert.deepEqual(await readdir(resolve(w.dir, "idunn")), ["incidents.cc"], "no lock or temp file beside the store"); +}); + +test("notice content carries no sensitive text", async () => { + const w = await world("wn-egress-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + const [closed, open] = tuples; + const longer = [...open, "extra-CANARYFIELD"]; + const pathSubject = ["idunn.operator_incident.v1", `continuity-exhausted:/etc/CANARYPATH x:${open[4]}`, "continuity-exhausted", "/etc/CANARYPATH x", open[4], null, null]; + const otherSchema = ["idunn.operator_incident.v0", "CANARYSCHEMA", "continuity-exhausted", "x", 1, null, null]; + return [closed, longer, pathSubject, otherSchema]; + }); + const result = w.run(); + assert.notEqual(result.status, 0, "refused records fail the run"); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 1, refused: 2, unknown: 0, failing: 0 }); + const posts = await w.posts(); + assert.deepEqual(posts.map((post) => opt(post, "source-id")), [CLOSED_KEY, CLOSED_KEY]); + for (const post of posts) assert.match(post.content, /^Idunn incident (opened: continuity-exhausted on `closed-target` at \d{4}-[\d-]+T[\d:.]+Z|closed \(recovered\): continuity-exhausted on `closed-target`, opened [\dTZ:.-]+, closed [\dTZ:.-]+)$/); + for (const text of [result.stdout, result.stderr, (await w.status()).stdout, await readFile(w.journal, "latin1"), JSON.stringify(posts)]) { + for (const canary of ["CANARYFIELD", "CANARYPATH", "CANARYSCHEMA"]) assert.ok(!text.includes(canary), "no byte of a refused record is echoed"); + } +}); + +test("no recipient means no journal write and a non-zero exit", async () => { + for (const value of [undefined, "", " "]) { + const w = await world("wn-recipient-"); + const env = value === undefined ? { DISCORD_OWNER_ID: undefined } : { DISCORD_OWNER_ID: value }; + const result = spawnSync(process.execPath, [TOOL, "process", "--incident-store", w.incidents, "--journal-store", w.journal, "--receipt-store", w.receipts, "--bridge-cli", w.bridge], { encoding: "utf8", env: { ...process.env, ...env } }); + assert.notEqual(result.status, 0); + assert.ok(!existsSync(w.journal)); + assert.deepEqual(await w.posts(), []); + } +}); + +test("an absent incident store is a clean no-op", async () => { + const w = await world("wn-absent-", { incidents: null }); + const result = w.run(); + assert.equal(result.status, 0, result.stderr); + assert.ok(!existsSync(w.journal)); + assert.deepEqual(await w.posts(), []); +}); + +test("an emptied or restored incident store never re-sends", async () => { + const w = await world("wn-restore-"); + assert.equal(w.run().status, 0); + const keys = async () => (await w.status()).rows.map((row) => `${row.incidentKey}#${row.notice}`).sort(); + const all = [`${CLOSED_KEY}#closed`, `${CLOSED_KEY}#opened`, `${OPEN_KEY}#opened`]; + assert.deepEqual(await keys(), all); + await variantStore(w.incidents, (tuples) => [tuples[1]]); + w.run(); + await variantStore(w.incidents, () => []); + w.run(); + await writeFile(w.incidents, ""); + w.run(); + assert.deepEqual(await keys(), all, "absence from Idunn's store deletes nothing"); + await copyFile(FIXTURE, w.incidents); + assert.equal(w.run().status, 0); + assert.equal((await w.posts()).length, 3, "exactly one post per notice across all views of the store"); +}); + +test("a bridge that dies after sending is unknown and never re-posted", async () => { + for (const mode of ["die", "signal"]) { + const w = await world(`wn-${mode}-`, { mode }); + const first = w.run(); + assert.notEqual(first.status, 0); + assert.equal(JSON.parse(first.stdout).unknown, 2); + assert.notEqual((await w.status()).status, 0, "status exits non-zero while an entry is unknown"); + await w.setMode("ok"); + assert.notEqual(w.run({ env: at(1000) }).status, 0, "far past any backoff"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0); + const posts = await w.posts(); + assert.equal(posts.length, 2, "one opening post per incident, no retry, no closure"); + assert.ok(posts.every((post) => notice(post) === "opened")); + assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"]); + assert.ok(!(await readFile(w.journal, "latin1")).includes("RECIPIENTCANARY")); + } +}); + +test("a spawn timeout and a spawn error are unknown, never failed, never re-posted", async () => { + for (const mode of ["hang", "flood"]) { + const w = await world(`wn-${mode}-`, { mode }); + const first = w.run({ env: { WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS: "1500" } }); + assert.notEqual(first.status, 0, mode); + assert.equal(JSON.parse(first.stdout).unknown, 2, mode); + await w.setMode("ok"); + assert.notEqual(w.run({ env: at(1000) }).status, 0, "far past any backoff"); + assert.notEqual(w.run({ env: at(100_000) }).status, 0); + const posts = await w.posts(); + assert.equal(posts.length, 2, `${mode}: one opening post per incident, no retry, no closure`); + assert.ok(posts.every((post) => notice(post) === "opened")); + assert.deepEqual((await w.status()).rows.map((row) => `${row.notice}:${row.status}:${row.attempts}:${row.lastError}`), ["opened:unknown:1:bridge-exit-unknown", "opened:unknown:1:bridge-exit-unknown"], mode); + } +}); + +test("errors name a code and never echo a path or an input", async () => { + const w = await world("wn-canary-", { incidents: null }); + await mkdir(resolve(w.dir, "CANARYDIR")); + const corrupt = resolve(w.dir, "CANARYDIR", "incidents.cc"); + await writeFile(corrupt, "not a cultcache store CANARYBYTES"); + const result = spawnSync(process.execPath, [TOOL, "process", "--incident-store", corrupt, "--journal-store", resolve(w.dir, "CANARYDIR", "j.cc"), "--receipt-store", w.receipts, "--bridge-cli", w.bridge], { encoding: "utf8", env: { ...process.env, DISCORD_OWNER_ID: RECIPIENT } }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /incident-store-unreadable/); + assert.ok(!(result.stdout + result.stderr).includes("CANARY")); +}); + +// The real bridge, reached through the real reader, with Discord's HTTP API +// stubbed at fetch (NODE_OPTIONS reaches the spawned bridge). +async function fetchStub(dir) { + const log = resolve(dir, "fetch.jsonl"); + const stub = resolve(dir, "fetch-stub.mjs"); + await writeFile(stub, ` +import { appendFileSync } from "node:fs"; +globalThis.fetch = async (url, init) => { + appendFileSync(${JSON.stringify(log)}, JSON.stringify({ url: String(url), body: JSON.parse(init.body) }) + "\\n"); + const channels = String(url).endsWith("/users/@me/channels"); + const behavior = process.env.STUB_BEHAVIOR; + if (channels && behavior === "dm-channel-500") return new Response("{}", { status: 500 }); + const rejected = /^message-(\\d{3})$/.exec(behavior ?? ""); + if (!channels && rejected) return new Response("{}", { status: Number(rejected[1]) }); + if (!channels && behavior === "message-network-error") throw new TypeError("fetch failed"); + return new Response(JSON.stringify(channels ? { id: "dm-channel" } : { id: "message-1" }), { status: 200 }); +}; +`); + return { log, options: `--import=${pathToFileURL(stub).href}` }; +} +const fetches = async (log) => (existsSync(log) ? (await readFile(log, "utf8")).trim().split("\n").map((line) => JSON.parse(line)) : []); + +test("discord-dm carries nonce and enforce_nonce when asked", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "wn-bridge-")); + const stub = await fetchStub(dir); + const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; + const dm = (...extra) => spawnSync(process.execPath, [BRIDGE, "discord-dm", "--recipient-id", "1", "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc"), ...extra], { encoding: "utf8", env }); + + const with_nonce = dm("--nonce", "abc123"); + assert.equal(with_nonce.status, 0, with_nonce.stderr); + let posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); + assert.equal(posted.at(-1).body.nonce, "abc123"); + assert.equal(posted.at(-1).body.enforce_nonce, true); + assert.deepEqual(posted.at(-1).body.allowed_mentions, { parse: [] }, "no mention is ever parsed"); + + const without = dm(); + assert.equal(without.status, 0, without.stderr); + posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/channels/dm-channel/messages")); + assert.ok(!("nonce" in posted.at(-1).body) && !("enforce_nonce" in posted.at(-1).body)); + + const before = (await fetches(stub.log)).length; + const tooLong = dm("--nonce", "x".repeat(26)); + assert.notEqual(tooLong.status, 0); + assert.equal((await fetches(stub.log)).length, before, "an over-long nonce posts nothing"); +}); + +test("discord-dm exits 75 only before delivery", async () => { + const dir = await mkdtemp(resolve(tmpdir(), "wn-exit75-")); + const stub = await fetchStub(dir); + const dm = (behavior, { token = "test-token-not-real", recipient = "1" } = {}) => { + const env = { ...process.env, NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: token, DISCORD_BOT_TOKEN: token }; + if (behavior) env.STUB_BEHAVIOR = behavior; + if (!token) { delete env.BIFROST_DISCORD_BOT_TOKEN; delete env.DISCORD_BOT_TOKEN; } + const args = ["discord-dm", ...(recipient ? ["--recipient-id", recipient] : []), "--content", "hi", "--cultmesh-command-id", "cmd", "--receipt-store", resolve(dir, "r.cc")]; + return spawnSync(process.execPath, [BRIDGE, ...args], { encoding: "utf8", env }); + }; + const messagePosts = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).length; + + assert.equal(dm(undefined, { token: "" }).status, 75, "missing token"); + assert.equal(dm(undefined, { recipient: "" }).status, 75, "missing argument"); + assert.equal(dm("dm-channel-500").status, 75, "DM channel failure"); + assert.equal(await messagePosts(), 0, "nothing was posted by any of those"); + assert.equal(dm("message-400").status, 75, "a 4xx answer to the message POST"); + assert.equal(await messagePosts(), 1); + for (const status of [500, 502, 503, 429]) { + const before = await messagePosts(); + assert.equal(dm(`message-${status}`).status, 75, `a ${status} answer is retryable, not unknown`); + assert.equal(await messagePosts(), before + 1); + } + const lost = dm("message-network-error"); + assert.equal(lost.status, 1, "a network error after the message POST was sent is not exit 75"); + assert.equal(await messagePosts(), 6); + assert.equal(dm().status, 0); +}); + +test("a 5xx or 429 on the message POST is retried with the same nonce", async () => { + for (const status of [502, 429]) { + const w = await world(`wn-${status}-`); + const stub = await fetchStub(w.dir); + const env = { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" }; + const messages = async () => (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); + for (let tick = 1; tick <= 2; tick += 1) { + const result = w.run({ bridge: BRIDGE, env: { ...env, ...at(tick === 1 ? 0 : 1), STUB_BEHAVIOR: `message-${status}` } }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual((await w.status()).rows.map((row) => `${row.status}:${row.attempts}`), [`failed:${tick}`, `failed:${tick}`]); + } + const sent = await messages(); + assert.equal(sent.length, 4, "two openings, each tried twice"); + const nonces = Map.groupBy(sent, (entry) => entry.body.content); + assert.equal(nonces.size, 2); + for (const group of nonces.values()) assert.equal(new Set(group.map((entry) => entry.body.nonce)).size, 1, "a retry reuses its nonce"); + assert.equal(w.run({ bridge: BRIDGE, env: { ...env, ...at(3) } }).status, 0); + assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); + } +}); + +test("every subject Idunn accepts is delivered, inside backticks", async () => { + const good = ["gamecult/odin", "odin:store", "_odin", "a".repeat(256), "Odin.v2-x"]; + const bad = ["a".repeat(257), "has space", "back`tick", "new\nline", "", "ün"]; + const w = await world("wn-subject-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + return [...good, ...bad].map((subject, index) => { + const opened = 1_700_000_200_000 + index; + return ["idunn.operator_incident.v1", `continuity-exhausted:${subject}:${opened}`, "continuity-exhausted", subject, opened, null, null]; + }); + }); + const stub = await fetchStub(w.dir); + const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: bad.length, unknown: 0, failing: 0 }); + const contents = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")).map((entry) => entry.body.content).sort(); + const at = (index) => new Date(1_700_000_200_000 + index).toISOString(); + assert.deepEqual(contents, good.map((subject, index) => OPENED_TEXT(subject, at(index))).sort()); + for (const content of contents) assert.match(content, /on `[A-Za-z0-9_.:/-]{1,256}` at /); +}); + +test("an out-of-range time refuses the record without aborting the run", async () => { + const w = await world("wn-time-", { incidents: null }); + await variantStore(w.incidents, (tuples) => { + const [closed, open] = tuples; + const hugeClose = [...closed]; + hugeClose[5] = 8.7e15; + hugeClose[1] = closed[1]; + const hugeOpen = ["idunn.operator_incident.v1", "continuity-exhausted:huge-open:8700000000000000", "continuity-exhausted", "huge-open", 8.7e15, null, null]; + const edge = ["idunn.operator_incident.v1", "continuity-exhausted:edge:8640000000000000", "continuity-exhausted", "edge", 8.64e15, null, null]; + return [hugeClose, hugeOpen, edge, open]; + }); + const result = w.run(); + assert.equal(result.stderr, "", "no unexpected-error"); + assert.deepEqual(JSON.parse(result.stdout), { skipped: 0, refused: 2, unknown: 0, failing: 0 }); + assert.notEqual(result.status, 0, "refused records fail the run"); + assert.deepEqual((await w.posts()).map((post) => opt(post, "source-id")).sort(), ["continuity-exhausted:edge:8640000000000000", OPEN_KEY]); + assert.ok((await w.status()).rows.every((row) => row.status === "completed")); +}); + +test("the reader posts through the real bridge with its nonce", async () => { + const w = await world("wn-real-bridge-"); + const stub = await fetchStub(w.dir); + const result = w.run({ bridge: BRIDGE, env: { NODE_OPTIONS: stub.options, BIFROST_DISCORD_BOT_TOKEN: "test-token-not-real" } }); + assert.equal(result.status, 0, result.stderr); + const posted = (await fetches(stub.log)).filter((entry) => entry.url.endsWith("/messages")); + assert.deepEqual(posted.map((entry) => entry.body.content).sort(), [CLOSED_TEXT, OPENED_TEXT("closed-target", T_OPEN), OPENED_TEXT("open-target", T_CLOSE)].sort()); + for (const entry of posted) { + assert.equal(entry.body.nonce.length, 25); + assert.equal(entry.body.enforce_nonce, true); + } + assert.ok(existsSync(w.receipts), "the bridge wrote its crossing receipts to the reader's receipt store"); + assert.deepEqual((await w.status()).rows.map((row) => row.status), ["completed", "completed", "completed"]); + assert.equal((await w.status()).status, 0, "status exits zero when nothing is unknown or failing"); +}); + diff --git a/tools/agent-transport.mjs b/tools/agent-transport.mjs index 0940caa..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); @@ -41,7 +43,7 @@ const { const { CultNetDocumentRegistry, defineCultNetDocumentBinding, -} = cultNetRequire("cultnet-ts"); +} = cultNetRequire("@gamecult/cultnet-ts"); const { encode, decode } = cultNetRequire("@msgpack/msgpack"); function resolveFirstExisting(label, candidates) { @@ -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 5b4179b..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}`); } @@ -470,11 +471,36 @@ async function postDiscordMessage(options) { }); } +// discord-dm exits NOT_SENT_EXIT only when it knows no message was delivered: +// anything before the message POST, or a non-2xx answer to it. A 5xx or 429 +// is not proof of non-delivery (Discord may have created the message before +// answering): the caller retries with the same nonce and relies on +// enforce_nonce inside Discord's window. A failure after the POST was sent +// (network error, receipt completion, output) keeps exit 1, so the caller +// treats the outcome as unknown. +const NOT_SENT_EXIT = 75; +class NotSentError extends Error {} +class DiscordRejection extends Error {} + async function sendDiscordDm(options) { + const state = { postStarted: false }; + try { + await deliverDiscordDm(options, state); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw state.postStarted && !(error instanceof DiscordRejection) ? error : new NotSentError(message); + } +} + +async function deliverDiscordDm(options, state) { ensureBridgeReceiptGate(options); const token = process.env.BIFROST_DISCORD_BOT_TOKEN ?? process.env.DISCORD_BOT_TOKEN; const recipientId = requireOption(options, "recipient-id"); const content = await readOptionText(options, "content", "content-file"); + const nonce = optionalString(options.nonce); + if (options.nonce !== undefined && (!nonce || nonce.length > 25)) { + throw new Error("--nonce must be 1 to 25 characters."); + } const dryRun = options["dry-run"] === "true"; if (dryRun) { @@ -506,7 +532,8 @@ async function sendDiscordDm(options) { throw new Error("Set BIFROST_DISCORD_BOT_TOKEN or DISCORD_BOT_TOKEN before sending a Discord DM."); } channelId = await openDiscordDmChannel(token, recipientId); - result = await postDiscordBotMessage(token, channelId, content, undefined); + state.postStarted = true; + result = await postDiscordBotMessage(token, channelId, content, undefined, nonce); await bridgeAction?.complete({ receiptUrl: `https://discord.com/channels/@me/${channelId}/${result.id}`, externalReceiptId: result.id, @@ -755,7 +782,7 @@ async function openDiscordDmChannel(token, recipientId) { return channel.id; } -async function postDiscordBotMessage(token, channelId, content, replyToMessageId) { +async function postDiscordBotMessage(token, channelId, content, replyToMessageId, nonce) { const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { @@ -773,12 +800,13 @@ async function postDiscordBotMessage(token, channelId, content, replyToMessageId allowed_mentions: { parse: [], }, + ...(nonce ? { nonce, enforce_nonce: true } : {}), }), }); const text = await response.text(); if (!response.ok) { - throw new Error(`Discord post failed with ${response.status}: ${text}`); + throw new DiscordRejection(`Discord post failed with ${response.status}: ${text}`); } const message = JSON.parse(text); @@ -1445,5 +1473,5 @@ Provenance note: main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; + process.exitCode = error instanceof NotSentError ? NOT_SENT_EXIT : 1; }); 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/cultmesh-bridge-commands.mjs b/tools/cultmesh-bridge-commands.mjs index 20b98a5..8d4885b 100644 --- a/tools/cultmesh-bridge-commands.mjs +++ b/tools/cultmesh-bridge-commands.mjs @@ -131,7 +131,6 @@ async function deleteCommand(options) { } async function processOneCommand(node, command, options) { - const verb = resolveBridgeVerb(command); const running = { ...command, status: "running", @@ -142,16 +141,18 @@ async function processOneCommand(node, command, options) { const payload = command.payload && typeof command.payload === "object" ? command.payload : {}; const tempDir = resolve(repoRoot, ".bifrost", "cultmesh-command-payloads", command.commandId); const contentPath = resolve(tempDir, "content.md"); - await mkdir(tempDir, { recursive: true }); - await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); try { + if ((optionalString(command.command) ?? "discord-post") !== "discord-post") { + throw new Error("Unsupported Bifrost bridge command; only discord-post is supported."); + } + await mkdir(tempDir, { recursive: true }); + await writeFile(contentPath, requireString(payload.content, "payload.content"), "utf8"); const args = [ "tools/bifrost-bridge.mjs", - verb, - ...(verb === "discord-dm" - ? ["--recipient-id", requireString(payload.recipientId, "payload.recipientId")] - : ["--channel-id", requireString(payload.channelId, "payload.channelId")]), + "discord-post", + "--channel-id", + requireString(payload.channelId, "payload.channelId"), "--content-file", contentPath, "--cultmesh-command-id", @@ -163,11 +164,9 @@ async function processOneCommand(node, command, options) { "--identity", optionalString(command.actor?.id) ?? optionalString(payload.identityId) ?? optionalString(payload.personaName) ?? "bifrost", ]; - if (verb === "discord-post") { - pushOption(args, "--persona-name", payload.personaName); - pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); - pushOption(args, "--reply-to-message-id", payload.replyToMessageId); - } + pushOption(args, "--persona-name", payload.personaName); + pushOption(args, "--persona-avatar-url", payload.personaAvatarUrl); + pushOption(args, "--reply-to-message-id", payload.replyToMessageId); pushOption(args, "--receipt-store", options["receipt-store"]); const result = spawnSync(process.execPath, args, { @@ -182,11 +181,11 @@ async function processOneCommand(node, command, options) { if (result.status !== 0 || result.error) { throw new Error(renderSpawnFailure(result)); } - const posted = parseJson(result.stdout, `bifrost bridge ${verb} receipt`); + const posted = parseJson(result.stdout, "bifrost bridge discord-post receipt"); const receipt = buildReceipt(command, { status: "completed", ok: true, - action: verb, + action: "discord-post", channelId: posted.channelId, messageId: posted.messageId, transport: posted.transport, @@ -206,7 +205,7 @@ async function processOneCommand(node, command, options) { const receipt = buildReceipt(command, { status: "failed", ok: false, - action: verb, + action: "discord-post", canonicalReceiptId: `crossing_${command.commandId}`, error: error instanceof Error ? error.message : String(error), }); @@ -224,18 +223,6 @@ async function processOneCommand(node, command, options) { } } -const supportedBridgeVerbs = new Set(["discord-post", "discord-dm"]); - -function resolveBridgeVerb(command) { - const verb = optionalString(command.command) ?? "discord-post"; - if (!supportedBridgeVerbs.has(verb)) { - throw new Error( - `Unsupported Bifrost bridge command "${verb}". Supported: ${[...supportedBridgeVerbs].join(", ")}.`, - ); - } - return verb; -} - function buildReceipt(command, result) { const completedAt = new Date().toISOString(); return { @@ -267,7 +254,7 @@ function listCommandRecords(node) { } return node.cache.getAll(commandDefinition()) .map(unwrapRecord) - .filter((entry) => entry && entry.commandId && supportedBridgeVerbs.has(entry.command ?? "discord-post")); + .filter((entry) => entry && entry.commandId); } async function openCommandNode(storePath) { 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/operator-notification.mjs b/tools/operator-notification.mjs deleted file mode 100644 index 7b98030..0000000 --- a/tools/operator-notification.mjs +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { createHash } from "node:crypto"; -import { - discordPostCommandDefinition, - discordPostCommandDocumentType as commandDocumentType, - discordPostCommandSchemaId as commandSchemaId, - discordPostReceiptDefinition, - discordPostReceiptDocumentType as receiptDocumentType, -} from "./bifrost-discord-command-documents.mjs"; - -const repoRoot = resolve(import.meta.dirname, ".."); -const projectsRoot = resolve(repoRoot, ".."); - -// The gate's own provider store. Bifrost owns the Discord crossing, so an alarm -// is a command addressed to the gate, not a document dropped into somebody -// else's state. cultmesh-bridge-commands.mjs reads this same path by default. -const defaultStorePath = resolve(repoRoot, ".bifrost", "provider-store.cc"); - -async function main() { - const [verb, ...rawArgs] = process.argv.slice(2); - const options = parseArgs(rawArgs); - - if (!verb || verb === "help" || verb === "--help" || verb === "-h") { - printHelp(); - return; - } - - switch (verb) { - case "publish-idunn-alarm": - await publishIdunnAlarm(options); - return; - default: - throw new Error(`Unknown command "${verb}". Run "node tools/operator-notification.mjs help".`); - } -} - -async function publishIdunnAlarm(options) { - const storePath = resolve(options.store ?? defaultStorePath); - const alarm = { - alarmId: readOptionOrEnv(options, "alarm-id", "IDUNN_ALARM_ID", "unknown-alarm"), - daemonId: readOptionOrEnv(options, "daemon-id", "IDUNN_ALARM_DAEMON_ID", "unknown-daemon"), - severity: readOptionOrEnv(options, "severity", "IDUNN_ALARM_SEVERITY", "operator-action-required"), - reason: readOptionOrEnv(options, "reason", "IDUNN_ALARM_REASON", "Idunn raised an operator alarm."), - raisedAt: readOptionOrEnv(options, "raised-at", "IDUNN_ALARM_RAISED_AT", new Date().toISOString()), - }; - // DISCORD_OWNER_ID is the established GameCult name for this, used by VoidBot's - // config schema. Read it first rather than minting another spelling. - const recipientId = - optionalString(options["recipient-id"]) ?? - optionalString(process.env.DISCORD_OWNER_ID) ?? - optionalString(process.env.IDUNN_OPERATOR_DISCORD_ID); - if (!recipientId) { - throw new Error( - "Set --recipient-id, DISCORD_OWNER_ID, or IDUNN_OPERATOR_DISCORD_ID so Bifrost knows which operator to reach.", - ); - } - - const command = buildDiscordDmCommand(alarm, recipientId); - const waitSeconds = - Number.parseInt(readOptionOrEnv(options, "wait-seconds", "IDUNN_ALARM_WAIT_SECONDS", "0"), 10) || 0; - - // Build the runtime and definitions before honouring --dry-run. A dry run that - // skips the runtime cannot notice a broken document definition, which is how - // this publisher shipped unable to publish anything at all. - const { CultMesh, defineDocumentType } = loadCultMeshRuntime(); - const commandDefinition = discordPostCommandDefinition(defineDocumentType); - const receiptDefinition = discordPostReceiptDefinition(defineDocumentType); - - if (options["dry-run"] === "true") { - printJson({ - dryRun: true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - command, - }); - return; - } - - const node = await CultMesh.createNode(storePath, { - documents: [commandDefinition, receiptDefinition], - }); - await node.put(commandDefinition, command.commandId, command); - await node.flush?.(); - - const receipt = - waitSeconds > 0 - ? await waitForReceipt(node, receiptDefinition, command.commandId, waitSeconds * 1000) - : undefined; - - printJson({ - ok: receipt ? receipt.ok === true : true, - action: "publish-idunn-alarm", - storePath, - documentType: commandDocumentType, - schemaId: commandSchemaId, - commandId: command.commandId, - delivery: receipt - ? { - status: receipt.status, - ok: receipt.ok, - messageId: receipt.messageId, - transport: receipt.transport, - error: receipt.error, - } - : { status: "pending", note: "Not awaited. Pass --wait-seconds to confirm delivery." }, - }); - - // A receipt that says the crossing failed must fail the caller. Idunn reads - // the exit code; reporting ok:false on stdout and exiting 0 would hand it a - // success it can act on. - if (receipt && receipt.ok !== true) { - process.exitCode = 1; - } -} - -function buildDiscordDmCommand(alarm, recipientId) { - const commandId = `idunn-alarm-${createHash("sha1").update(JSON.stringify(alarm)).digest("hex").slice(0, 16)}`; - const content = [ - "Idunn needs operator intervention.", - "", - `Service: ${alarm.daemonId}`, - `Severity: ${alarm.severity}`, - `Reason: ${alarm.reason}`, - `Alarm: ${alarm.alarmId}`, - `Raised: ${alarm.raisedAt}`, - "", - "Automatic recovery either was not authorized or failed.", - ].join("\n"); - const requestedAt = new Date().toISOString(); - - const command = { - schemaName: commandDocumentType, - schemaVersion: commandSchemaId, - commandId, - command: "discord-dm", - status: "pending", - requestedBy: "idunn", - requestedAt, - updatedAt: requestedAt, - source: { kind: "idunn-operator-alarm", id: alarm.alarmId }, - actor: { id: "idunn", displayName: "Idunn" }, - payload: { recipientId, content }, - }; - - const commandUri = optionalString(process.env.BIFROST_CULTMESH_COMMAND_URI); - if (commandUri) { - command.commandUri = commandUri; - } - return command; -} - -async function waitForReceipt(node, definition, commandId, timeoutMs) { - const startedAt = Date.now(); - while (Date.now() - startedAt <= timeoutMs) { - await node.cache?.pullAllBackingStores?.(); - const receipt = await node.get(definition, commandId); - if (receipt) { - return receipt; - } - await new Promise((done) => setTimeout(done, 250)); - } - throw new Error( - `Timed out after ${Math.round(timeoutMs / 1000)}s waiting for a ${receiptDocumentType} for ${commandId}. ` + - "Is the Bifrost bridge command pump running?", - ); -} - -function loadCultMeshRuntime() { - const candidates = [ - resolve(projectsRoot, "CultLib", "packages", "cultmesh-ts", "package.json"), - resolve(projectsRoot, "CultMeshTS", "package.json"), - ]; - - for (const packageJson of candidates) { - if (!existsSync(packageJson)) { - continue; - } - try { - const requireCult = createRequire(packageJson); - const { CultMesh } = requireCult("cultmesh-ts"); - const { defineDocumentType } = requireCult("cultcache-ts"); - if (CultMesh && defineDocumentType) { - return { CultMesh, defineDocumentType }; - } - } catch { - } - } - - throw new Error("CultMesh/CultCache TypeScript runtime is unavailable."); -} - -function readOptionOrEnv(options, optionName, envName, fallback) { - return optionalString(options[optionName]) ?? optionalString(process.env[envName]) ?? fallback; -} - -function parseArgs(args) { - const options = {}; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (!token.startsWith("--")) { - throw new Error(`Unexpected argument "${token}".`); - } - const key = token.slice(2); - const next = args[index + 1]; - if (!next || next.startsWith("--")) { - options[key] = "true"; - continue; - } - options[key] = next; - index += 1; - } - return options; -} - -function optionalString(value) { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function printJson(value) { - process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); -} - -function printHelp() { - process.stdout.write(`Bifrost operator notification CultMesh publisher - -Publishes an Idunn operator alarm as a ${commandSchemaId} document addressed to -the Bifrost Discord gate. Bifrost owns the crossing; this tool only asks. The -bridge command pump turns the command into a DM and writes back a -${receiptDocumentType}.v1 receipt. - -Commands: - publish-idunn-alarm Publish an Idunn alarm as a discord-dm gate command - -Options: - --recipient-id Operator Discord user id (or DISCORD_OWNER_ID / - IDUNN_OPERATOR_DISCORD_ID) - --store Gate provider store (default: .bifrost/provider-store.cc) - --wait-seconds Wait for the delivery receipt instead of returning at publish - --dry-run Build the command and definitions without writing - -Examples: - node tools/operator-notification.mjs publish-idunn-alarm --dry-run - node tools/operator-notification.mjs publish-idunn-alarm --daemon-id voidbot --reason "restart failed" --wait-seconds 30 -`); -} - -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -}); 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 new file mode 100644 index 0000000..afdc7f3 --- /dev/null +++ b/tools/persona-chat-documents.mjs @@ -0,0 +1,393 @@ +// gamecult.persona.chat_*.v1, the v2 send pair and the v2 pending answer: the documents Huginn and the Matrix mouth exchange. +// Each kind is a positional tuple (the CultCache payload shape); this module owns +// the field order, the parsers and the envelope check. It does no I/O and loads no +// runtime, so the wire encoder (MessagePack) stays with the caller. The image path, mime and size bounds it checks +// come from persona-chat-images.mjs, which owns them. + +import { IMAGE_PATH, MAX_BYTES, MIMES, extensionOf } from "./persona-chat-images.mjs"; + +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", + sendReceipt: "gamecult.persona.chat_send_receipt.v1", + 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"]); + +// 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, tailPerRoom: 20, tailTotal: 60, replyExcerpt: 240 }); + +// `value` cut to at most `maxBytes` of UTF-8 without splitting a character. +export function clip(value, maxBytes) { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + return Buffer.from(value, "utf8").subarray(0, maxBytes).toString("utf8").replace(/�$/, ""); +} + +export function pendingRequestTuple({ persona, afterSeq }) { + return [text(persona, "persona"), seq(afterSeq, "after_seq")]; +} +export function parsePendingRequest(t) { + const [persona, afterSeq] = tuple(t, 2, "chat_pending_request"); + 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")]; +} +export function parseAck(t) { + const [persona, throughSeq] = tuple(t, 2, "chat_ack"); + return { persona: text(persona, "persona"), throughSeq: seq(throughSeq, "through_seq") }; +} + +// Huginn's report that a delivery has gone into the model's input: she has read through this event of this room. +// at_unix_ms is when the model took it in; the mouth does not trust it for anything but the log. +export function readTuple({ persona, roomAlias, eventId, atUnixMs }) { + return [text(persona, "persona"), text(roomAlias, "room_alias"), text(eventId, "event_id"), seq(atUnixMs, "at_unix_ms")]; +} +export function parseRead(t) { + const [persona, roomAlias, eventId, atUnixMs] = tuple(t, 4, "chat_read"); + return { persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), eventId: text(eventId, "event_id"), atUnixMs: seq(atUnixMs, "at_unix_ms") }; +} + +export function sendRequestTuple({ requestId, persona, roomAlias, text: body }) { + return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body)]; +} +export function parseSendRequest(t) { + const [requestId, persona, roomAlias, body] = tuple(t, 4, "chat_send_request"); + if (typeof body !== "string") throw new Error("chat_send_request text must be a string"); + if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound"); + return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body }; +} + +export function sendReceiptTuple({ requestId, eventId = null, refusal = null }) { + if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal"); + if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${refusal}`); + return [text(requestId, "request_id"), eventId, refusal]; +} +export function parseSendReceipt(t) { + const [requestId, eventId, refusal] = tuple(t, 3, "chat_send_receipt"); + if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal"); + if (refusal !== null && !refusals.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`); + return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal }; +} + +// v2: the same send plus a basis. basis is null (her choice stands: no check) or { seenEventId|null, sinceUnixMs }. +export function sendRequestV2Tuple({ requestId, persona, roomAlias, text: body, basis = null }) { + return [text(requestId, "request_id"), text(persona, "persona"), text(roomAlias, "room_alias"), String(body), + basis === null ? null : [basis.seenEventId === null ? null : text(basis.seenEventId, "seen_event_id"), seq(basis.sinceUnixMs, "since_unix_ms")]]; +} +export function parseSendRequestV2(t) { + const [requestId, persona, roomAlias, body, basis] = tuple(t, 5, "chat_send_request_v2"); + if (typeof body !== "string") throw new Error("chat_send_request text must be a string"); + if (Buffer.byteLength(body, "utf8") > maxTextBytes) throw new Error("chat_send_request text is over the byte bound"); + let parsed = null; + if (basis !== null) { + const [seen, since] = tuple(basis, 2, "basis"); + parsed = { seenEventId: seen === null ? null : text(seen, "seen_event_id"), sinceUnixMs: seq(since, "since_unix_ms") }; + } + return { requestId: text(requestId, "request_id"), persona: text(persona, "persona"), roomAlias: text(roomAlias, "room_alias"), text: body, basis: parsed }; +} + +// `newer` is the ids of the messages that moved the room, oldest first: non-empty exactly when the refusal is RoomMoved. +function checkReceiptV2(eventId, refusal, newer) { + if ((eventId === null) === (refusal === null)) throw new Error("a send receipt carries exactly one of event_id and refusal"); + if (refusal !== null && !refusalsV2.includes(refusal)) throw new Error(`unknown refusal ${String(refusal)}`); + if (!Array.isArray(newer) || newer.length > maxNewer || (newer.length > 0) !== (refusal === "RoomMoved")) throw new Error("newer is non-empty exactly when the refusal is RoomMoved, and at most 50 ids"); + return newer.map(id => text(id, "newer")); +} +export function sendReceiptV2Tuple({ requestId, eventId = null, refusal = null, newer = [] }) { + return [text(requestId, "request_id"), eventId, refusal, checkReceiptV2(eventId, refusal, newer)]; +} +export function parseSendReceiptV2(t) { + const [requestId, eventId, refusal, newer] = tuple(t, 4, "chat_send_receipt_v2"); + return { requestId: text(requestId, "request_id"), eventId: eventId === null ? null : text(eventId, "event_id"), refusal, newer: checkReceiptV2(eventId, refusal, newer) }; +} + +// A typed refusal for a request that names a Persona the mouth does not serve. +export function refusalTuple({ persona, 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 (!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"), 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), + ]; +} + +// The image the mouth stored for a delivery, by reference: [path, mime, size, sha256] or null. The path is relative to the +// Persona's image store and is exactly `/[.]`, so it can +// be neither absolute nor climb out of the store. Whether the file matches the rest is the reader's to re-check. +function imageTuple(image) { + if (image === null) return null; + const { path, mime, size, sha256 } = image; + if (typeof path !== "string" || !IMAGE_PATH.test(path)) throw new Error("image path must be /[.ext]"); + if (!MIMES.includes(mime)) throw new Error("image mime must be image/png, image/jpeg, image/gif or image/webp"); + if (path.includes(".") && !path.endsWith(`.${extensionOf(mime)}`)) throw new Error("image path extension must match its mime"); + if (!Number.isSafeInteger(size) || size < 1 || size > MAX_BYTES) throw new Error("image size must be 1 to 5 MiB"); + if (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)) throw new Error("image sha256 must be lowercase hex"); + if (!path.split("/")[1].startsWith(sha256)) throw new Error("image path must name its sha256"); + return [path, mime, size, sha256]; +} +function parseImage(t) { + const [path, mime, size, sha256] = tuple(t, 4, "image"); + 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: 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), + }; +} + +export function pendingTuple({ persona, deliveries }) { + return [text(persona, "persona"), deliveries.map(deliveryTuple)]; +} +export function parsePending(t) { + const [persona, deliveries] = tuple(t, 2, "chat_pending"); + if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list"); + 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 { + schemaVersion: "cultnet.document_put_raw.v0", + messageId: `${kind}:${recordKey}`, + document: { + schemaId: kind, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack", payload, + sourceRuntimeId, sourceAgentId, sourceRole, tags: ["cultnet.transport.rudp.v0"], + }, + }; +} + +// 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 !== 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; +} + +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; +} +function flag(v, what) { + if (typeof v !== "boolean") throw new Error(`${what} must be a boolean`); + return v; +} +function seq(v, what) { + if (!Number.isSafeInteger(v) || v < 0) throw new Error(`${what} must be a non-negative integer`); + return v; +} diff --git a/tools/persona-chat-images.mjs b/tools/persona-chat-images.mjs new file mode 100644 index 0000000..587327f --- /dev/null +++ b/tools/persona-chat-images.mjs @@ -0,0 +1,228 @@ +// Chat images for the Matrix mouth: fetch one m.image attachment from the Persona's own homeserver, decide what it +// is from its bytes alone, and keep it in a per-Persona store the Persona's reader opens by reference. +// +// Nothing in the bytes or in the event is trusted. The type is the magic bytes (the declared mime and the filename +// are never read), the size and pixel counts are bounded before anything decodes the image, and every name on disk is +// generated here from hashes. Refusals are a fixed word list so a refusal can be printed without printing sender text. +import { createHash, randomBytes } from "node:crypto"; +import { closeSync, fsyncSync, mkdirSync, openSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs"; +import { join } from "node:path"; + +export const MAX_BYTES = 5 * 1024 * 1024; +export const MAX_PIXELS = 40_000_000; +export const ROOM_CAP_BYTES = 50 * 1024 * 1024; +export const RETAIN_MS = 24 * 60 * 60 * 1000; +export const FILE_MODE = 0o640; +export const DIR_MODE = 0o750; // a room directory's mode at mkdir; its setgid bit comes from the Persona's directory, never from a chmod +export const FETCH_TIMEOUT_MS = 30000; + +// Why an image was not stored. Printed in the delivery's text line, so only these words ever are. +export const refusals = Object.freeze(["encrypted", "no-url", "fetch-failed", "too-big", "wrong-type", "bad-header", "too-many-pixels", "store-failed"]); + +const MXC = /^mxc:\/\/([A-Za-z0-9.:_-]+)\/([A-Za-z0-9_-]+)$/; +const HEX64 = /^[0-9a-f]{64}$/; +export const IMAGE_PATH = /^[0-9a-f]{64}\/[0-9a-f]{64}(\.[a-z0-9]{1,5})?$/; +const EXT = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" }; +export const MIMES = Object.freeze(Object.keys(EXT)); +export const extensionOf = mime => EXT[mime]; + +const u16le = (b, o) => b[o] | (b[o + 1] << 8); +const u16be = (b, o) => (b[o] << 8) | b[o + 1]; +const u32be = (b, o) => b.readUInt32BE(o); +const ascii = (b, from, to) => b.toString("latin1", from, to); + +// The type and the pixel dimensions from the header alone, never decoding. null when the bytes are none of the four +// types; { mime, width: 0, height: 0 } when the type is known and the header will not parse. +export function sniff(b) { + if (b.length >= 8 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + const ok = b.length >= 24 && ascii(b, 12, 16) === "IHDR"; + return { mime: "image/png", width: ok ? u32be(b, 16) : 0, height: ok ? u32be(b, 20) : 0 }; + } + if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return { mime: "image/jpeg", ...jpegSize(b) }; + if (b.length >= 6 && (ascii(b, 0, 6) === "GIF87a" || ascii(b, 0, 6) === "GIF89a")) { + return { mime: "image/gif", width: b.length >= 10 ? u16le(b, 6) : 0, height: b.length >= 10 ? u16le(b, 8) : 0 }; + } + if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP") return { mime: "image/webp", ...webpSize(b) }; + return null; +} + +function jpegSize(b) { + let i = 2; + while (i + 4 <= b.length) { + if (b[i] !== 0xff) return { width: 0, height: 0 }; + while (b[i] === 0xff && i + 1 < b.length) i++; // fill bytes + const marker = b[i++]; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) continue; // no length + if (i + 2 > b.length) break; + const length = u16be(b, i); + const frame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + if (frame) return i + 7 <= b.length ? { height: u16be(b, i + 3), width: u16be(b, i + 5) } : { width: 0, height: 0 }; + if (marker === 0xda || length < 2) break; // scan data before any frame header: unparsable + i += length; + } + return { width: 0, height: 0 }; +} + +function webpSize(b) { + const bad = { width: 0, height: 0 }; + const chunk = ascii(b, 12, 16); + if (chunk === "VP8 ") return b.length >= 30 && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a ? { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff } : bad; + if (chunk === "VP8L") { + if (b.length < 25 || b[20] !== 0x2f) return bad; + return { width: 1 + (b[21] | ((b[22] & 0x3f) << 8)), height: 1 + ((b[22] >> 6) | (b[23] << 2) | ((b[24] & 0x0f) << 10)) }; + } + if (chunk === "VP8X") return b.length >= 30 ? { width: 1 + (b[24] | (b[25] << 8) | (b[26] << 16)), height: 1 + (b[27] | (b[28] << 8) | (b[29] << 16)) } : bad; + return bad; +} + +// The checked type of `bytes`: { mime, ext } or { refusal }. +export function classify(bytes) { + const found = sniff(bytes); + if (!found) return { refusal: "wrong-type" }; + if (!(found.width > 0 && found.height > 0)) return { refusal: "bad-header" }; + if (found.width * found.height > MAX_PIXELS) return { refusal: "too-many-pixels" }; + return { mime: found.mime, ext: EXT[found.mime] }; +} + +// 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, signal: outer }) { + const named = typeof mxc === "string" ? MXC.exec(mxc) : null; + if (!named) return { refusal: "no-url" }; + 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, + }); + if (!response.ok || !response.body) return { refusal: "fetch-failed" }; + const declared = Number(response.headers.get("content-length")); + if (response.headers.get("content-length") !== null && Number.isFinite(declared) && declared > MAX_BYTES) { + await response.body.cancel().catch(() => {}); + return { refusal: "too-big" }; + } + const chunks = []; + let total = 0; + const reader = response.body.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > MAX_BYTES) { + await reader.cancel().catch(() => {}); + return { refusal: "too-big" }; + } + chunks.push(value); + } + const bytes = Buffer.concat(chunks); + const kind = classify(bytes); + return kind.refusal ? kind : { bytes, ...kind }; + } catch { + return { refusal: "fetch-failed" }; + } +} + +const sha256 = data => createHash("sha256").update(data).digest("hex"); + +// One Persona's store: `//[.ext]`. The mouth is its only writer. +// A stored image leaves when it is at least RETAIN_MS old and the delivery that carried it is acked, whichever is +// later; a room holds at most ROOM_CAP_BYTES, oldest evicted first. What is on disk at start is old and acked: a +// delivery still pending after a restart is made again by the next sync and stores its image again. +export function createImageStore({ root, now = Date.now, log = () => {}, stat = statSync }) { + const entries = new Map(); // relative path -> { room, size, storedAt, seq } (seq: the newest delivery carrying it, null once acked) + let loaded = false; + + function load() { + if (loaded) return; + loaded = true; + for (const room of safeList(root)) { + if (!HEX64.test(room)) continue; + for (const name of safeList(join(root, room))) { + const path = `${room}/${name}`; + if (name.startsWith(".tmp-")) { try { unlinkSync(join(root, path)); } catch { /* gone */ } continue; } + if (!IMAGE_PATH.test(path)) continue; + try { const s = statSync(join(root, path)); if (s.isFile()) entries.set(path, { room, size: s.size, storedAt: s.mtimeMs, seq: null }); } catch { /* gone */ } + } + } + } + + function drop(path) { + try { unlinkSync(join(root, path)); } catch { /* already gone */ } + entries.delete(path); + } + + function sweep() { + load(); + const at = now(); + for (const [path, e] of [...entries]) if (e.seq === null && at - e.storedAt >= RETAIN_MS) drop(path); + } + + // Store `bytes` (already classified as `mime`) for `roomId`. Returns the delivery reference. + function put(roomId, bytes, mime) { + load(); + const room = sha256(roomId); + const digest = sha256(bytes); + const ext = EXT[mime]; + const path = `${room}/${digest}.${ext}`; + const dir = join(root, room); + // The Persona's directory (`root`) belongs to the installer: bifrost:mind-, 2750. It is never chmodded here, and + // neither is a room directory: bifrost is not in the Persona's group, and a chmod by a non-member clears setgid. A room + // directory inherits group and setgid from `root` at mkdir; if it did not, a file in it would take bifrost's group and the + // Persona's reader could not open it, so the image is refused instead. + // No chmod can set the group bits without risking setgid, so the creation modes must be exact: a fixed umask (it only + // withholds other-access) for the mkdir and the open, restored at once. Synchronous, so nothing else runs under it. + const umask = process.umask(0o027); + let temp, fd; + try { + // Only the room directory is created: a missing Persona directory is the installer's to make, and is not made up here. + let persona; + try { persona = stat(root); } catch { persona = null; } + if (!persona?.isDirectory()) throw Object.assign(new Error("the Persona's image directory does not exist"), { code: "ENOPERSONADIR" }); + try { mkdirSync(dir, { mode: DIR_MODE }); } catch (error) { if (error.code !== "EEXIST") throw error; } + const made = stat(dir); + if ((made.mode & 0o2000) === 0) throw Object.assign(new Error("image directory lacks the setgid bit"), { code: "ENOSETGID" }); + if (made.gid !== persona.gid) throw Object.assign(new Error("image directory is not in the Persona directory's group"), { code: "ENOGROUP" }); + temp = join(dir, `.tmp-${randomBytes(8).toString("hex")}`); + fd = openSync(temp, "wx", FILE_MODE); + } finally { process.umask(umask); } + try { + writeSync(fd, bytes); + fsyncSync(fd); + } finally { closeSync(fd); } + try { renameSync(temp, join(dir, `${digest}.${ext}`)); } catch (error) { try { unlinkSync(temp); } catch { /* gone */ } throw error; } + entries.set(path, { room, size: bytes.length, storedAt: now(), seq: entries.get(path)?.seq ?? null }); + evict(room, path); + sweep(); + return { path, mime, size: bytes.length, sha256: digest }; + } + + function evict(room, keep) { + const here = [...entries].filter(([, e]) => e.room === room); + let total = here.reduce((n, [, e]) => n + e.size, 0); + for (const [path, e] of here.sort((a, b) => a[1].storedAt - b[1].storedAt)) { + if (total <= ROOM_CAP_BYTES) break; + if (path === keep) continue; + drop(path); + total -= e.size; + } + } + + // The delivery numbered `seq` carries the image at `path`: it is kept at least until that delivery is acked. + function hold(path, seq) { + const e = entries.get(path); + if (e) e.seq = Math.max(e.seq ?? 0, seq); + } + + // Deliveries through `throughSeq` are acked. + function acked(throughSeq) { + load(); + for (const e of entries.values()) if (e.seq !== null && e.seq <= throughSeq) e.seq = null; + sweep(); + } + + return { put, hold, acked, sweep, get size() { load(); return entries.size; } }; +} + +function safeList(dir) { + try { return readdirSync(dir); } catch { return []; } +} diff --git a/tools/persona-discord-delivery-documents.mjs b/tools/persona-discord-delivery-documents.mjs deleted file mode 100644 index a99ff3b..0000000 --- a/tools/persona-discord-delivery-documents.mjs +++ /dev/null @@ -1,43 +0,0 @@ -export const requestType = "epiphany.persona_discord_delivery_request"; -export const requestSchema = "epiphany.persona_discord_delivery_request.v0"; -export const requestSigningPurpose = "epiphany.persona-discord.delivery-request.v0"; -export const requestIdentityDomain = "epiphany.persona-discord-delivery-request.identity.v0\0"; -export const requestSignatureDomain = "epiphany.persona-discord-delivery-request.signature.v0\0"; -export const receiptType = "bifrost.persona_discord_delivery_receipt"; -export const receiptSchema = "bifrost.persona_discord_delivery_receipt.v0"; -export const receiptSigningPurpose = "bifrost.persona-discord.delivery-receipt.v0"; -export const receiptIdentityDomain = "bifrost.persona-discord-delivery-receipt.identity.v0\0"; -export const receiptSignatureDomain = "bifrost.persona-discord-delivery-receipt.signature.v0\0"; -export const executionType = "bifrost.persona_discord_delivery_execution"; -export const executionSchema = "bifrost.persona_discord_delivery_execution.v0"; - -export function buildPersonaDiscordDeliveryDefinitions(defineDocumentType) { - const make=(type,schema,name,parse,members)=>defineDocumentType({type,schemaName:type,schemaId:schema,schemaVersion:schema,contentHash:schema,global:false,name,schema:{parse},members}); - return { - request:make(requestType,requestSchema,"requestId",parseRequest,requestMembers), - receipt:make(receiptType,receiptSchema,"receiptId",parseReceipt,receiptMembers), - execution:make(executionType,executionSchema,"requestId",parseExecution), - }; -} - -const requestMembers = ["schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="signerSignature"?"byte[]":"string",isName:memberName==="requestId"})); -const receiptMembers = ["schemaVersion","receiptId","requestId","requestPayloadSha256","status","channelId","replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl","completedAt","providerIdentityId","privateStateExposed","providerSignature"].map((memberName,slot)=>({slot,memberName,typeName:memberName==="privateStateExposed"?"boolean":memberName==="providerSignature"?"byte[]":"string",isName:memberName==="receiptId"})); - -export function requestSigningTuple(v){return [v.schemaVersion,v.requestId,v.effectDocumentId,v.targetRuntimeId,v.personaAgentId,v.channelId,v.replyToMessageId,v.content,v.contentSha256,v.issuedAt,v.expiresAt,v.privateStateExposed,v.signerIdentityId];} -export function receiptSigningTuple(v){return [v.schemaVersion,v.receiptId,v.requestId,v.requestPayloadSha256,v.status,v.channelId,v.replyToMessageId,v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl,v.completedAt,v.providerIdentityId,v.privateStateExposed];} -export function receiptDocumentTuple(v){return [...receiptSigningTuple(v),v.providerSignature];} - -export function parseRequest(value){const v=typed(value,requestMembers,"Persona Discord delivery request");closed(v,["schemaName","schemaVersion","requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","replyToMessageId","content","contentSha256","issuedAt","expiresAt","privateStateExposed","signerIdentityId","signerSignature"],"request");exact(v.schemaVersion,requestSchema,"request schema");for(const key of ["requestId","effectDocumentId","targetRuntimeId","personaAgentId","channelId","contentSha256","issuedAt","expiresAt","signerIdentityId"])text(v[key],key);content(v.content);optionalText(v.replyToMessageId,"replyToMessageId");if(v.privateStateExposed!==false)throw new Error("request privateStateExposed must be false.");bytes64(v.signerSignature,"signerSignature");sha(v.contentSha256,"contentSha256");const issued=date(v.issuedAt,"issuedAt"),expires=date(v.expiresAt,"expiresAt");if(expires<=issued||expires-issued>120000)throw new Error("request lifetime must be positive and at most 120 seconds.");return v;} -export function parseReceipt(value){const v=typed(value,receiptMembers,"Persona Discord delivery receipt");exact(v.schemaVersion,receiptSchema,"receipt schema");for(const key of ["receiptId","requestId","requestPayloadSha256","status","channelId","completedAt","providerIdentityId"])text(v[key],key);for(const key of ["replyToMessageId","messageId","transport","crossingReceiptId","receiptUrl"])optionalText(v[key],key);if(v.receiptId!==v.requestId)throw new Error("receiptId must equal requestId.");if(!["completed","failed","unknown"].includes(v.status))throw new Error("receipt status is invalid.");if(v.status==="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(x=>!x))throw new Error("completed receipt lacks exact delivery evidence.");if(v.status!=="completed"&&[v.messageId,v.transport,v.crossingReceiptId,v.receiptUrl].some(Boolean))throw new Error("non-completed receipt may not claim delivery evidence.");if(v.privateStateExposed!==false)throw new Error("receipt privateStateExposed must be false.");bytes64(v.providerSignature,"providerSignature");sha(v.requestPayloadSha256,"requestPayloadSha256");date(v.completedAt,"completedAt");return v;} -export function parseExecution(value){const v=record(value,"Persona Discord execution journal");exact(v.schemaVersion,executionSchema,"execution schema");for(const key of ["requestId","requestPayloadSha256","status","recordedAt"])text(v[key],key);if(!["permit_pending","running","completed","failed","unknown"].includes(v.status))throw new Error("execution status is invalid.");if(v.status==="permit_pending"){text(v.permitNonce,"permitNonce");date(v.permitIssuedAt,"permitIssuedAt");date(v.permitExpiresAt,"permitExpiresAt");if(Date.parse(v.permitExpiresAt)<=Date.parse(v.permitIssuedAt)||Date.parse(v.permitExpiresAt)-Date.parse(v.permitIssuedAt)>5000)throw new Error("permit intent lifetime is invalid");}return v;} -function record(v,label){if(!v||typeof v!=="object"||Array.isArray(v))throw new Error(`${label} must be an object.`);return v;} -function typed(v,members,label){if(Array.isArray(v)){if(v.length!==members.length)throw new Error(`${label} tuple has the wrong arity.`);return Object.fromEntries(members.map(member=>[member.memberName,member.typeName==="byte[]"&&Array.isArray(v[member.slot])?Uint8Array.from(v[member.slot]):v[member.slot]]));}return record(v,label);} -function text(v,label){bounded(v,label,256);} -function bounded(v,label,maxBytes){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>maxBytes||/\p{Cc}/u.test(v))throw new Error(`${label} is empty, oversized, or contains control characters.`);} -function content(v){if(typeof v!=="string"||!v.trim()||Buffer.byteLength(v,"utf8")>1900||v.includes("\0"))throw new Error("content is empty, exceeds 1900 UTF-8 bytes, or contains NUL.");} -function optionalText(v,label){if(typeof v!=="string")throw new Error(`${label} must be a string.`);} -function exact(v,w,label){if(v!==w)throw new Error(`${label} must be ${w}.`);} -function bytes64(v,label){if(!(v instanceof Uint8Array)||v.length!==64)throw new Error(`${label} must be 64 bytes.`);} -function sha(v,label){if(!/^sha256-[0-9a-f]{64}$/.test(v))throw new Error(`${label} must be lowercase sha256.`);} -function date(v,label){const parsed=Date.parse(v);if(Number.isNaN(parsed))throw new Error(`${label} must be RFC3339.`);return parsed;} -function closed(v,keys,label){const allowed=new Set(keys);for(const key of Object.keys(v))if(!allowed.has(key))throw new Error(`${label} contains unknown field ${key}.`);} diff --git a/tools/persona-discord-delivery.mjs b/tools/persona-discord-delivery.mjs deleted file mode 100644 index 832bf66..0000000 --- a/tools/persona-discord-delivery.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import {createHash,randomBytes} from "node:crypto"; -import {spawnSync} from "node:child_process"; -import {access,readFile,writeFile,mkdir} from "node:fs/promises"; -import {constants} from "node:fs"; -import {dirname,resolve} from "node:path"; -import {enrollDomainIdentity,loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs"; -import {buildPersonaDiscordDeliveryDefinitions,executionSchema,receiptIdentityDomain,receiptSchema,receiptSignatureDomain,receiptSigningPurpose,receiptSigningTuple,requestSignatureDomain,requestSigningPurpose,requestSigningTuple} from "./persona-discord-delivery-documents.mjs"; -import {requestPersonaDiscordPermit} from "./persona-discord-permit.mjs"; -import {permitRequestIdentityDomain,permitRequestPurpose,permitRequestSchema} from "./persona-discord-permit.mjs"; - -export async function enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath,trustAnchorOut}){const identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain),anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",receiptSigningPurpose,"bifrost.persona_discord_delivery_receipt.v0","root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} -export async function exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath,trustAnchorOut}){let identity;try{await access(privateKeyPath,constants.F_OK);identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;identity=await enrollDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain);}const anchor=["gamecult.service_trust_anchor.v1","bifrost-persona-discord-delivery-permit-request:bifrost-discord-yggdrasil:v0","bifrost-persona-discord-delivery","bifrost-discord-yggdrasil",identity.identityId,new Uint8Array(identity.publicKey),"ed25519",permitRequestPurpose,permitRequestSchema,"root",Date.now(),null,false];await mkdir(dirname(trustAnchorOut),{recursive:true});await writeFile(trustAnchorOut,runtime.encode(anchor),{flag:"wx",mode:0o640});return {identityId:identity.identityId,privateKeyPath,trustAnchorOut};} - -export async function processPersonaDiscordDeliveries(options){ - const {runtime,requestStorePath,receiptStorePath,executionStorePath,epiphanyTrustAnchorPath,privateKeyPath,targetAdmitted}=options,defs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType); - requireSeparate([requestStorePath,receiptStorePath,executionStorePath]); - const requests=await open(runtime,defs.request,requestStorePath),receipts=await open(runtime,defs.receipt,receiptStorePath),executions=await open(runtime,defs.execution,executionStorePath),anchor=await readAnchor(runtime,epiphanyTrustAnchorPath,options.epiphanyRuntimeId),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord delivery identity",receiptIdentityDomain); - const results=[]; - for(const request of requests.cache.getAll(defs.request).sort((a,b)=>String(a.issuedAt).localeCompare(String(b.issuedAt)))){ - const unsigned=runtime.encode(requestSigningTuple(request)),digest=`sha256-${sha(unsigned)}`,existingReceipt=unwrap(receipts.cache.get(defs.receipt,request.requestId)),existingExecution=unwrap(executions.cache.get(defs.execution,request.requestId)); - if(existingReceipt){if(existingReceipt.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its terminal receipt.`);continue;} - if(existingExecution){if(existingExecution.requestPayloadSha256!==digest)throw new Error(`Persona Discord request ${request.requestId} conflicts with its execution journal.`);if(existingExecution.status==="running"){results.push(await terminal("unknown",request,digest,"delivery outcome was indeterminate after service interruption",{runtime,defs,receipts,executions,identity}));continue;}if(existingExecution.status!=="permit_pending")continue;} - let failure=""; - try{validateRequest(request,unsigned,anchor,targetAdmitted);}catch(error){failure=error instanceof Error?error.message:String(error);} - if(failure){results.push(await terminal("failed",request,digest,failure,{runtime,defs,receipts,executions,identity}));continue;} - if(existingExecution&&Date.parse(existingExecution.permitExpiresAt)now+30_000)throw new Error("request issuance is in the future");if(expires<=issued||expires120_000)throw new Error("request lifetime exceeds 120 seconds");if(!targetAdmitted(request))throw new Error("request target is not an exact configured Bifrost Persona binding");} -function post(request,options){const args=[options.bridgeCliPath,"discord-post","--cultmesh-command-id",request.requestId,"--channel-id",request.channelId,"--content",request.content,"--identity",request.personaAgentId,"--source-kind","epiphany_persona_speech","--source-id",request.effectDocumentId,"--authority-ref","epiphany.persona_discord_delivery_request.v0","--epiphany-lane-id","Persona","--epiphany-agent-identity",request.personaAgentId,"--receipt-store",options.crossingReceiptStorePath??resolve(dirname(options.executionStorePath),"persona-discord-crossing-receipts.cc")];if(request.replyToMessageId)args.push("--reply-to-message-id",request.replyToMessageId);const capability=process.env.HEIMDALL_CAPABILITY_REF||process.env.BIFROST_HEIMDALL_CAPABILITY_REF;if(capability)args.push("--heimdall-capability-ref",capability);const result=spawnSync(process.execPath,args,{cwd:options.bifrostRoot,encoding:"utf8",windowsHide:true,env:process.env});if(result.status!==0||result.error)throw new Error(`Bifrost Discord transport returned indeterminate outcome: ${result.error?.message||result.stderr||result.stdout}`);const value=JSON.parse(result.stdout);if(value.action!=="discord-post"||value.ok!==true||value.channelId!==request.channelId||!value.messageId||!value.crossingReceiptId||!value.url)throw new Error("Bifrost Discord transport did not return exact delivery evidence");return value;} -async function terminal(status,request,digest,detail,c){const now=new Date().toISOString(),posted=c.posted??{},receipt={schemaName:"bifrost.persona_discord_delivery_receipt",schemaVersion:receiptSchema,receiptId:request.requestId,requestId:request.requestId,requestPayloadSha256:digest,status,channelId:request.channelId,replyToMessageId:request.replyToMessageId,messageId:status==="completed"?posted.messageId:"",transport:status==="completed"?posted.transport:"",crossingReceiptId:status==="completed"?posted.crossingReceiptId:"",receiptUrl:status==="completed"?posted.url:"",completedAt:now,providerIdentityId:c.identity.identityId,privateStateExposed:false,providerSignature:new Uint8Array()};receipt.providerSignature=new Uint8Array(signDomainPurpose(c.identity,receiptSignatureDomain,receiptSigningPurpose,c.runtime.encode(receiptSigningTuple(receipt))));await c.receipts.cache.put(c.defs.receipt,receipt.receiptId,receipt);await c.executions.cache.put(c.defs.execution,request.requestId,{schemaName:"bifrost.persona_discord_delivery_execution",schemaVersion:executionSchema,requestId:request.requestId,requestPayloadSha256:digest,status,recordedAt:now,detail});return receipt;} -async function open(runtime,definition,path){await mkdir(dirname(path),{recursive:true});const cache=runtime.CultCache.builder().withDocumentType(definition).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build();await cache.pullAllBackingStores();return {cache};} -async function accessStoreOrWritableParent(path,mode){try{await access(path,mode);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(path),constants.W_OK);}} -async function readAnchor(runtime,path,expectedRuntimeId){if(typeof expectedRuntimeId!=="string"||!expectedRuntimeId)throw new Error("Epiphany runtime identity is required for Persona mouth trust");const value=runtime.decode(await readFile(path));if(!Array.isArray(value)||value.length!==13)throw new Error("Epiphany Persona mouth trust anchor must be the exact service-contract tuple");const [schemaVersion,trustAnchorId,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,bindingAuthority,boundAt,expiresAt,privateStateExposed]=value,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null,now=Date.now();if(schemaVersion!=="gamecult.service_trust_anchor.v1"||typeof trustAnchorId!=="string"||serviceId!=="epiphany-persona-mouth"||runtimeId!==expectedRuntimeId||typeof identityId!=="string"||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==requestSigningPurpose||signedSchema!=="epiphany.persona_discord_delivery_request.v0"||bindingAuthority!=="root"||!Number.isSafeInteger(boundAt)||boundAt<=0||boundAt>now||(expiresAt!==null&&(!Number.isSafeInteger(expiresAt)||expiresAt<=boundAt||expiresAt<=now))||privateStateExposed!==false)throw new Error("Epiphany Persona mouth service trust anchor is invalid");return {identityId,publicKey};} -function requireSeparate(paths){const keys=paths.map(p=>resolve(p).toLowerCase());if(new Set(keys).size!==keys.length)throw new Error("Persona Discord crossing and private execution stores must be distinct.");} -function sha(value){return createHash("sha256").update(value).digest("hex");} -function unwrap(value){return Array.isArray(value)?value[0]:value?.value??value;} diff --git a/tools/persona-discord-permit.mjs b/tools/persona-discord-permit.mjs deleted file mode 100644 index 9d14e77..0000000 --- a/tools/persona-discord-permit.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import {createHash,randomBytes} from "node:crypto"; -import {createSocket} from "node:dgram"; -import {createRequire} from "node:module"; -import {existsSync} from "node:fs"; -import {readFile} from "node:fs/promises"; -import {resolve} from "node:path"; -import {loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs"; - -export const permitRequestSchema="bifrost.persona_discord_delivery_permit_request.v0"; -export const permitSchema="epiphany.persona_discord_delivery_permit.v0"; -export const permitRequestPurpose="bifrost.persona-discord.delivery-permit-request.v0"; -export const permitPurpose="epiphany.persona-discord.delivery-permit.v0"; -export const permitRequestIdentityDomain="bifrost.persona-discord-delivery-permit-request.identity.v0\0"; -export const permitRequestSignatureDomain="bifrost.persona-discord-delivery-permit-request.signature.v0\0"; -export const permitIdentityDomain="epiphany.persona-discord-delivery-permit.identity.v0\0"; -export const permitSignatureDomain="epiphany.persona-discord-delivery-permit.signature.v0\0"; -const connectionId=0xe91f0002; - -export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed]; -export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed]; -export const permitRequestWireTuple=v=>[...permitRequestTuple(v),v.requesterSignature]; -export function parsePermitWireTuple(v){if(!Array.isArray(v)||v.length!==15)throw new Error("Epiphany permit must be an exact Rust wire tuple");const [schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature]=v;return {schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature};} - -export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){ - const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath,targetRuntimeId),now=Date.now(); - const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()}; - request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request)))); - const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10}); - try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(permitRequestWireTuple(request)),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();} -} - -export function validatePermit(value,request,anchor,encode){const v=Array.isArray(value)?parsePermitWireTuple(value):value,providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expiresissued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};} -export function parseEpiphanyPermitAnchorTuple(a,expectedRuntimeId,now=Date.now()){if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null;if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};} -async function readAnchor(runtime,path,expectedRuntimeId){return parseEpiphanyPermitAnchorTuple(runtime.msgpack.decode(await readFile(path)),expectedRuntimeId);} -function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=parsePermitWireTuple(m.decode(d?.payload));if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})} -async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}} -function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})} -function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}} -function loadRuntime(){const root=resolve(import.meta.dirname,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(root,"..","CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error("CultNet runtime unavailable");const req=createRequire(pkg);return {cultnet:req("cultnet-ts"),msgpack:req("@msgpack/msgpack")};} diff --git a/tools/persona-discord-rudp.mjs b/tools/persona-discord-rudp.mjs deleted file mode 100644 index d3ebfe6..0000000 --- a/tools/persona-discord-rudp.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import {createSocket} from "node:dgram"; -import {parseRequest,receiptDocumentTuple} from "./persona-discord-delivery-documents.mjs"; - -export const personaDiscordDeliveryConnectionId=0xe91f0003; - -export async function startPersonaDiscordDeliveryRudpServer({runtime,endpoint,runtimeId="bifrost-discord-yggdrasil",onRequest,socketFactory=createSocket,connectionFactory}){ - const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4"); - await bind(socket,host,port); - const connection=(connectionFactory??(options=>new runtime.cultnet.CultNetRudpSocketTransportConnection(options)))({runtimeId,socket,mode:"server",connectionId:personaDiscordDeliveryConnectionId,initialSequence:100,resendDelayMs:50,resendPollMs:10}); - let busy=false;const diagnostics={datagrams:0,frames:0,responses:0,errors:[]};socket.on("message",()=>diagnostics.datagrams++); - const frame=async value=>{ - diagnostics.frames++; - if(busy)return; - busy=true; - try{ - if(value.channelId!=="schema")throw new Error("Persona delivery request arrived on a foreign channel"); - const message=runtime.cultnet.parseCultNetMessage(runtime.msgpack.decode(value.payload),"cultnet.schema.v0"),document=message.document; - if(message.schemaVersion!=="cultnet.document_put_raw.v0"||document.schemaId!=="epiphany.persona_discord_delivery_request.v0"||document.payloadEncoding!=="messagepack"||document.sourceRole!=="epiphany-persona-mouth"||document.sourceRuntimeId!=="epiphany-starfire"||!Array.isArray(document.tags)||document.tags.length!==1||document.tags[0]!=="cultnet.transport.rudp.v0")throw new Error("Persona delivery request envelope substituted authority"); - const request=parseRequest(runtime.msgpack.decode(document.payload)); - if(document.recordKey!==request?.requestId||document.sourceAgentId!==request?.signerIdentityId||request?.targetRuntimeId!=="epiphany-starfire")throw new Error("Persona delivery request envelope is not bound to its signed payload"); - const receipt=await onRequest(request); - const response={schemaVersion:"cultnet.document_put_raw.v0",messageId:`persona-delivery-receipt-${receipt.requestId}`,document:{schemaId:"bifrost.persona_discord_delivery_receipt.v0",recordKey:receipt.requestId,storedAt:receipt.completedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(receiptDocumentTuple(receipt)),sourceRuntimeId:runtimeId,sourceAgentId:receipt.providerIdentityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}}; - connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(response,"cultnet.schema.v0")));diagnostics.responses++; - }catch(error){const message=error instanceof Error?error.message:String(error);diagnostics.errors.push(message);console.error(`Bifrost Persona delivery RUDP rejected frame: ${message}`);}finally{busy=false;} - }; - connection.on("frame",frame); - return {address:socket.address(),diagnostics,close(){connection.off?.("frame",frame);connection.close();}}; -} - -function parseEndpoint(value){const url=new URL(value);if(url.protocol!=="rudp:"||!url.hostname||!url.port)throw new Error("Persona delivery listener must be rudp://host:port");return {host:url.hostname,port:Number(url.port)};} -function bind(socket,host,port){return new Promise((resolve,reject)=>{socket.once("error",reject);socket.once("listening",resolve);socket.bind(port,host);});} diff --git a/tools/persona-feedback-idunn-health.mjs b/tools/persona-feedback-idunn-health.mjs index d341d18..646bcfc 100644 --- a/tools/persona-feedback-idunn-health.mjs +++ b/tools/persona-feedback-idunn-health.mjs @@ -6,7 +6,7 @@ import { providerHealthIdentity, signProviderHealth } from "./bifrost-feedback-i const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")); const require=createRequire(resolve(cult,"packages","cultnet-ts","package.json")); -const {encodeCultNetMessageForWire,encodeRudpPacket}=require("cultnet-ts"),{encode}=require("@msgpack/msgpack"); +const {encodeCultNetMessageForWire,encodeRudpPacket}=require("@gamecult/cultnet-ts"),{encode}=require("@msgpack/msgpack"); const protocol="cultnet.transport.rudp.v0",schema="idunn.signed_daemon_health.v1",sourceRuntimeId="bifrost-persona-feedback"; export const signedHealthSourceRole="daemon-health-publisher"; const states=new Set(["active","warming","degraded","failed"]),reasons=new Set(["ready","starting","readiness-degraded","unavailable"]); diff --git a/tools/persona-feedback.mjs b/tools/persona-feedback.mjs index b586a58..a49c1f4 100644 --- a/tools/persona-feedback.mjs +++ b/tools/persona-feedback.mjs @@ -8,18 +8,12 @@ import { buildFeedbackDefinitions, actorLinkSchema, feedbackBindingSchema, feedb import { defaultFeedbackIdentityPath, enrollBifrostFeedbackIdentity, loadBifrostFeedbackIdentity, providerHealthTrustAnchor, signFeedbackAdmission, trustAnchor } from "./bifrost-feedback-identity.mjs"; import { createPersonaFeedbackHealthPublisher, publishPersonaFeedbackHealth } from "./persona-feedback-idunn-health.mjs"; import { discordGatewayReady, serveDiscordPersonaIngress } from "./bifrost-discord-persona-ingress.mjs"; -import { enrollPersonaDiscordDeliveryIdentity, exportPersonaDiscordPermitRequestAnchor, inspectPersonaDiscordDeliveryReadiness, processPersonaDiscordDeliveries } from "./persona-discord-delivery.mjs"; -import { startPersonaDiscordDeliveryRudpServer } from "./persona-discord-rudp.mjs"; -import { buildPersonaDiscordDeliveryDefinitions, parseRequest as parsePersonaDiscordDeliveryRequest } from "./persona-discord-delivery-documents.mjs"; const root=resolve(import.meta.dirname,".."),projects=resolve(root,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(projects,"CultLib")); const [command,...rawArgs]=process.argv.slice(2),options=parseArgs(rawArgs); const privateStorePath=resolve(options.store||(process.platform==="win32"?resolve(root,".bifrost","provider-store.cc"):"/var/lib/gamecult/bifrost/persona-feedback/provider.cc")); const observationStorePath=resolve(options["observation-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-conversation-observations.cc"):"/srv/bifrost/persona-feedback/observations.cc")); const deliveryStorePath=resolve(options["delivery-store"]||(process.platform==="win32"?resolve(root,".bifrost","epiphany-persona-feedback.cc"):"/srv/bifrost/persona-feedback/deliveries.cc")); -const personaDeliveryRequestStorePath=resolve(options["persona-delivery-request-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-requests.cc"):"/srv/bifrost/persona-discord-crossing/requests.cc")); -const personaDeliveryReceiptStorePath=resolve(options["persona-delivery-receipt-store"]||(process.platform==="win32"?resolve(root,".bifrost","persona-discord-receipts.cc"):"/srv/bifrost/persona-discord-crossing/receipts.cc")); -const personaDeliveryExecutionStorePath=resolve(options["persona-delivery-execution-store"]||(process.platform==="win32"?resolve(root,".bifrost","private","persona-discord-executions.cc"):"/var/lib/gamecult/bifrost/persona-feedback/persona-discord-executions.cc")); const runtime=loadRuntime(),defs=buildFeedbackDefinitions(runtime.defineDocumentType); try{await main();}catch(error){console.error(error instanceof Error?error.message:String(error));process.exitCode=1;} @@ -30,14 +24,11 @@ async function main(){ if(command==="process")return processEvent(requireOption("event-id")); if(command==="serve")return serve(); if(command==="serve-discord")return serveDiscord(); - if(command==="serve-persona-delivery")return servePersonaDelivery(); if(command==="export")return exportSnapshot(); if(command==="enroll-identity")return enrollIdentity(); - if(command==="enroll-persona-delivery-identity")return enrollPersonaDeliveryIdentity(); - if(command==="export-persona-permit-request-anchor")return exportPersonaPermitRequestAnchor(); if(command==="export-provider-health-anchor")return exportProviderHealthAnchor(); if(command==="status")return status(); - throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|serve-persona-delivery|status|export|enroll-identity|enroll-persona-delivery-identity|export-provider-health-anchor"); + throw new Error("Usage: persona-feedback.mjs bind-target|link-actor|process|serve|serve-discord|status|export|enroll-identity|export-provider-health-anchor"); } async function privateNode(){return runtime.CultMesh.createNode(privateStorePath,{documents:[defs.binding,defs.actorLink,defs.receipt,defs.delivery,defs.pendingFailure]});} async function observationNode(){return runtime.CultMesh.createNode(observationStorePath,{documents:[defs.event]});} @@ -77,19 +68,14 @@ async function serveDiscord(){ const publishHealth=async()=>{const value=await readiness(),gatewayReady=discordGatewayReady(gatewayState),active=value.status==="ready"&&gatewayReady;await publishPersonaFeedbackHealth({endpoint:healthEndpoint,daemonId:"yggdrasil-bifrost-persona-feedback",healthContract:"bifrost.cultnet-rudp-persona-feedback-health",state:active?"active":"degraded",detail:active?"ready":"readiness-degraded",publisher:healthPublisher,publishUnsignedDiagnostic:true});return {...value,gatewayReady,gatewayState};}; const initial=await readiness();if(!initial.ingressReady)throw new Error(`Bifrost Discord Persona ingress is not ready: ${initial.ingressReasons.join("; ")}`); const recovered=await drainPendingObservations();if(recovered.some(value=>!value.ok))console.error(`Bifrost Persona ingress startup recovery retained ${recovered.filter(value=>!value.ok).length} failed observation(s) for reconnect retry.`); - await drainPersonaDeliveries().catch(error=>console.error(`Bifrost Persona delivery is staged but not yet ready: ${error instanceof Error?error.message:String(error)}`)); await publishHealth(); const healthTimer=setInterval(()=>publishHealth().catch(error=>console.error(`Bifrost Persona-feedback health publication failed: ${error instanceof Error?error.message:String(error)}`)),10000); let observationBusy=false;const observationTimer=setInterval(async()=>{if(observationBusy)return;observationBusy=true;try{const retried=await drainPendingObservations();if(retried.some(value=>!value.ok))console.error(`Bifrost Persona ingress retained ${retried.filter(value=>!value.ok).length} failed pending observation(s).`);}catch(error){console.error(`Bifrost Persona observation reconciliation failed: ${error instanceof Error?error.message:String(error)}`);}finally{observationBusy=false;}},positiveInteger(options["observation-retry-interval-ms"]||"5000","observation-retry-interval-ms")); - let deliveryBusy=false;const deliveryTimer=setInterval(async()=>{if(deliveryBusy)return;deliveryBusy=true;try{await drainPersonaDeliveries();}catch(error){console.error(`Bifrost Persona delivery poll failed: ${error instanceof Error?error.message:String(error)}`);}finally{deliveryBusy=false;}},positiveInteger(options["persona-delivery-interval-ms"]||"500","persona-delivery-interval-ms")); - try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);clearInterval(deliveryTimer);} + try{await serveDiscordPersonaIngress({token,runtimeId:producerRuntimeId,onConversation:conversation=>admitDiscordConversation(conversation,producerRuntimeId),onReady:async state=>{const drained=await drainPendingObservations();print({schemaVersion:"bifrost.discord.persona_ingress_status.v0",status:drained.every(value=>value.ok)?"ready":"degraded",runtimeId:state.runtimeId,botUserId:state.botUserId,recoveredObservationCount:drained.length,privateStateExposed:false});},onState:state=>{gatewayState=state;},onDisconnect:error=>console.error(`Bifrost Discord Persona ingress reconnecting: ${error instanceof Error?error.message:String(error)}`)});}finally{gatewayState={connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"stopped"};await publishHealth().catch(()=>{});clearInterval(healthTimer);clearInterval(observationTimer);} } -async function servePersonaDelivery(){const readinessState=await readiness();if(!readinessState.personaDelivery.ready)throw new Error(`Bifrost Persona delivery is not ready: ${readinessState.personaDelivery.reasons.join("; ")}`);const server=await startPersonaDiscordDeliveryRudpServer({runtime,endpoint:requireOption("persona-delivery-rudp-listen"),onRequest:acceptPersonaDeliveryRequest});print({schemaVersion:"bifrost.persona_discord_delivery_listener.v0",status:"ready",runtimeId:"bifrost-discord-yggdrasil",host:server.address.address,port:server.address.port,requestSignerIdentityId:readinessState.personaDelivery.requestSignerIdentityId,receiptSignerIdentityId:readinessState.personaDelivery.receiptSignerIdentityId,privateStateExposed:false});await new Promise(()=>{});} -async function drainPersonaDeliveries(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const bindings=n.cache.getAll(defs.binding),epiphanyRuntimeId=requireOption("epiphany-runtime-id");return processPersonaDiscordDeliveries({runtime,permitRuntime:{cultnet:runtime.cultnet,msgpack:runtime.msgpack},requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId,epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key")),bridgeCliPath:resolve(root,"tools","bifrost-bridge.mjs"),bifrostRoot:root,targetAdmitted:request=>request.targetRuntimeId===epiphanyRuntimeId&&request.personaAgentId==="epiphany.Persona"&&bindings.some(binding=>binding.targetRuntimeId===request.targetRuntimeId&&binding.targetPersonaId==="epiphany"&&binding.channelId===request.channelId)});} -async function acceptPersonaDeliveryRequest(request){const deliveryDefs=buildPersonaDiscordDeliveryDefinitions(runtime.defineDocumentType),requests=runtime.CultCache.builder().withDocumentType(deliveryDefs.request).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryRequestStorePath)).build();await requests.pullAllBackingStores();const parsed=parsePersonaDiscordDeliveryRequest(request),existing=unwrap(requests.get(deliveryDefs.request,parsed.requestId));if(existing&&stable(existing)!==stable(parsed))throw new Error(`Persona delivery request ${parsed.requestId} conflicts with durable endpoint state.`);if(!existing)await requests.put(deliveryDefs.request,parsed.requestId,parsed);await drainPersonaDeliveries();const receipts=runtime.CultCache.builder().withDocumentType(deliveryDefs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(personaDeliveryReceiptStorePath)).build();await receipts.pullAllBackingStores();const receipt=unwrap(receipts.get(deliveryDefs.receipt,parsed.requestId));if(!receipt)throw new Error(`Persona delivery request ${parsed.requestId} has no terminal Bifrost receipt yet.`);return receipt;} async function admitDiscordConversation(conversation,producerRuntimeId){ const privateNodeValue=await privateNode();await privateNodeValue.cache?.pullAllBackingStores?.(); @@ -104,14 +90,12 @@ async function admitDiscordConversation(conversation,producerRuntimeId){ } async function enrollIdentity(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await enrollBifrostFeedbackIdentity(keyPath),createdAt=new Date().toISOString(),anchor=trustAnchor(identity,createdAt),out=resolve(requireOption("trust-anchor-out")),providerOut=options["provider-health-trust-anchor-out"]?resolve(options["provider-health-trust-anchor-out"]):null;await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)));if(providerOut){if(providerOut===out)throw new Error("Feedback and provider-health trust anchors must use distinct paths.");const providerAnchor=providerHealthTrustAnchor(identity,createdAt);await mkdir(dirname(providerOut),{recursive:true});await writeFile(providerOut,runtime.encode(anchorTuple(providerAnchor)));}print({privateKey:keyPath,trustAnchor:out,identityId:identity.identityId,providerHealthTrustAnchor:providerOut??""});} -async function enrollPersonaDeliveryIdentity(){print(await enrollPersonaDiscordDeliveryIdentity({runtime,privateKeyPath:resolve(requireOption("private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} -async function exportPersonaPermitRequestAnchor(){print(await exportPersonaDiscordPermitRequestAnchor({runtime,privateKeyPath:resolve(requireOption("permit-request-private-key")),trustAnchorOut:resolve(requireOption("trust-anchor-out"))}));} async function exportProviderHealthAnchor(){const keyPath=resolve(options["private-key"]||defaultFeedbackIdentityPath()),identity=await loadBifrostFeedbackIdentity(keyPath),out=resolve(requireOption("trust-anchor-out")),anchor=providerHealthTrustAnchor(identity,new Date().toISOString());await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(anchorTuple(anchor)),{flag:"wx",mode:0o640});print({privateKey:keyPath,trustAnchor:out,identityId:anchor.identityId});} async function exportSnapshot(){const n=await privateNode();await n.cache?.pullAllBackingStores?.();const out=resolve(requireOption("out")),scratch=`${out}.scratch.cc`,cache=runtime.CultCache.builder().withDocumentType(defs.delivery).withDocumentType(defs.receipt).withGenericStore(new runtime.SingleFileMessagePackBackingStore(scratch)).build();for(const value of n.cache.getAll(defs.delivery))await cache.put(defs.delivery,value.admissionId,value);for(const value of n.cache.getAll(defs.receipt))await cache.put(defs.receipt,value.receiptId,value);const registry=new runtime.CultNetDocumentRegistry([runtime.defineCultNetDocumentBinding({definition:defs.delivery,payloadSchemaVersion:feedbackDeliverySchema}),runtime.defineCultNetDocumentBinding({definition:defs.receipt,payloadSchemaVersion:feedbackReceiptSchema})]),message=registry.createRawSnapshotResponse(cache,options["message-id"]||`persona-feedback-${randomUUID()}`);await mkdir(dirname(out),{recursive:true});await writeFile(out,runtime.encode(message));await rm(scratch,{force:true});print({schemaVersion:message.schemaVersion,messageId:message.messageId,documentCount:message.documents.length,out});} -async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const personaConfigured=command==="serve-discord"||command==="serve-persona-delivery"||Boolean(options["epiphany-persona-mouth-trust-anchor"]);let personaDelivery={ready:false,requestSignerIdentityId:"",receiptSignerIdentityId:"",permitRequesterIdentityId:"",reasons:["not configured"]};if(personaConfigured){personaDelivery=await inspectPersonaDiscordDeliveryReadiness({runtime,requestStorePath:personaDeliveryRequestStorePath,receiptStorePath:personaDeliveryReceiptStorePath,executionStorePath:personaDeliveryExecutionStorePath,epiphanyTrustAnchorPath:resolve(requireOption("epiphany-persona-mouth-trust-anchor")),epiphanyRuntimeId:requireOption("epiphany-runtime-id"),epiphanyPermitAnchorPath:resolve(requireOption("epiphany-persona-permit-trust-anchor")),epiphanyPermitEndpoint:requireOption("epiphany-persona-permit-rudp"),privateKeyPath:resolve(requireOption("persona-delivery-private-key")),permitRequesterPrivateKeyPath:resolve(requireOption("persona-permit-request-private-key"))});}const reasons=[...ingressReasons,...(personaConfigured?personaDelivery.reasons.map(reason=>`personaDelivery:${reason}`):[])],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,personaDelivery,status:ingressReady&&(!personaConfigured||personaDelivery.ready)?"ready":"degraded",reasons,privateStateExposed:false};} +async function readiness(){const ingressReasons=[];let identity,bindingCount=0,pendingFailedCount=0;try{identity=await loadBifrostFeedbackIdentity(resolve(options["private-key"]||defaultFeedbackIdentityPath()));}catch(error){ingressReasons.push(`identity:${error instanceof Error?error.message:String(error)}`);}let bindings=[];try{const info=await stat(privateStorePath);if(!info.isFile())throw new Error("private provider store is not a regular file");const cache=runtime.CultCache.builder().withDocumentType(defs.binding).withDocumentType(defs.actorLink).withDocumentType(defs.receipt).withDocumentType(defs.delivery).withDocumentType(defs.pendingFailure).withGenericStore(new runtime.SingleFileMessagePackBackingStore(privateStorePath)).build();await cache.pullAllBackingStores();bindings=cache.getAll(defs.binding);bindingCount=bindings.length;const admitted=new Set(cache.getAll(defs.receipt).map(value=>value.eventId));pendingFailedCount=cache.getAll(defs.pendingFailure).filter(value=>!admitted.has(value.eventId)).length;if(pendingFailedCount>0)ingressReasons.push(`pendingFailures:${pendingFailedCount}`);if(bindingCount===0)ingressReasons.push("bindings:none");}catch(error){ingressReasons.push(`privateStore:${error instanceof Error?error.message:String(error)}`);}for(const binding of bindings){try{await assertStoreSeparation(binding.deliveryStorePath);try{const info=await stat(binding.deliveryStorePath);if(!info.isFile())throw new Error("delivery path is not a regular file");await access(binding.deliveryStorePath,constants.W_OK);}catch(error){if(!error||typeof error!=="object"||error.code!=="ENOENT")throw error;await access(dirname(binding.deliveryStorePath),constants.W_OK);}}catch(error){ingressReasons.push(`binding:${binding.bindingId}:${error instanceof Error?error.message:String(error)}`);}}const reasons=[...ingressReasons],ingressReady=ingressReasons.length===0;return {schemaVersion:"bifrost.persona_feedback.readiness.v0",providerIdentityId:identity?.identityId??"",bindingCount,pendingFailedCount,ingressReady,ingressReasons,status:ingressReady?"ready":"degraded",reasons,privateStateExposed:false};} async function status(){print(await readiness());} -function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),cn=nr("cultnet-ts"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} +function loadRuntime(){const cr=createRequire(resolve(cult,"packages","cultcache-ts","package.json")),mr=createRequire(resolve(cult,"packages","cultmesh-ts","package.json")),nr=createRequire(resolve(cult,"packages","cultnet-ts","package.json")),cc=cr("@gamecult/cultcache-ts"),cn=nr("@gamecult/cultnet-ts"),mp=nr("@msgpack/msgpack");return {CultMesh:mr("@gamecult/cultmesh-ts").CultMesh,CultCache:cc.CultCache,SingleFileMessagePackBackingStore:cc.SingleFileMessagePackBackingStore,defineDocumentType:cc.defineDocumentType,CultNetDocumentRegistry:cn.CultNetDocumentRegistry,defineCultNetDocumentBinding:cn.defineCultNetDocumentBinding,cultnet:cn,msgpack:mp,encode:mp.encode,decode:mp.decode};} function bindingId(o){return `feedback:${o["guild-id"]}:${o["channel-id"]}:${o["runtime-id"]}:${o.repo}:${o["persona-id"]}:${o["producer-id"]||"bifrost-discord"}:${o["producer-runtime-id"]}`;} function bindingIdFromAdmission(admission){const p=admission.packet;return bindingId({"guild-id":p.discordGuildId,"channel-id":p.discordChannelId,"runtime-id":p.targetRuntimeId,repo:p.targetRepository,"persona-id":p.targetPersonaId,"producer-id":admission.sourceObserverId,"producer-runtime-id":admission.sourceObserverRuntimeId});} function packetTuple(p){return [p.feedbackId,p.sourceEventId,p.sourceActorId,p.actorClassification,p.actorLinkRefs,p.discordGuildId,p.discordChannelId,p.discordMessageId,p.targetRuntimeId,p.targetRepository,p.targetPersonaId,p.sourceRoomId,p.feedbackText,p.contentSha256,p.sourceDiscussionRefs,p.sourceVisibility,p.dataClassification,p.privateStateIncluded];} diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs new file mode 100644 index 0000000..0964830 --- /dev/null +++ b/tools/persona-matrix-mouth.mjs @@ -0,0 +1,1258 @@ +// The Matrix ear and mouth of every Persona. One process: one account (token) and +// one /sync loop per Persona, one CultNet endpoint for all of them. +// +// Ear: /sync the configured rooms (and the account's direct rooms); every message +// 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. 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 +// already posted in that room (fire and forget on the endpoint: a failure is logged once, not retried). +// Her presence: online while a turn is going on (a read report, a send or an ack within presenceGraceMs), +// unavailable once the grace has passed, offline while her brake is held. Every /sync carries the state +// and a change is also PUT at once, so a transition does not wait out the long poll. A brake placed mid +// long-poll shows at the next round, within syncTimeoutMs plus one PUT; a stopped mouth reads offline after +// the homeserver's sync timeout. +// Mouth: a send request becomes one PUT with txnId = request_id (a retry is idempotent), and a room +// is joined, only while that Persona's brake is released. A v2 send carries the basis of her turn (the +// newest room event she saw): inside the room gate, one step per room across every Persona, the mouth +// 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. +// +// Context: Matrix is the record. A delivery's `context` is read from the homeserver when the delivery +// is built (GET /messages, or the thread's relations for a threaded message), as this Persona's +// account, and holds m.room.message events from everyone, her own included, oldest first, ending +// just before the delivered event. Nothing about a room's past is kept in this process, so a restart +// loses none of it. A failed fetch gives empty context and a log line; the delivery still goes out. +// +// Each Matrix access token is read here, from its own systemd credential, and +// nowhere else. It is never put in a document, an environment passed to a child, +// or a log, and it is only ever presented in its own Persona's Authorization header. +// +// Everything outside this process is a port (fetch, state, brake, clock), so the +// core runs against a fake homeserver with no CultLib and no daemon. +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createSocket } from "node:dgram"; +import { resolve, join } from "node:path"; +import { pathToFileURL } from "node:url"; +import * as docs from "./persona-chat-documents.mjs"; +import { createImageStore, fetchImage } from "./persona-chat-images.mjs"; + +const API = "/_matrix/client/v3"; +const BRAKE_TYPE = "eureka.control.brake.v1"; +const CONTEXT = 20; // /sync timeline limit per round +const CONTEXT_LINES = 20; // the most context lines the delivery tuple carries (persona-chat-documents maxContext) +const CONTEXT_BYTES = 16 * 1024; // text and sender bytes over all lines of one delivery: under CONTEXT_LINES x limits.contextBytes +const FETCH_LIMIT = 100; // events asked of the homeserver per context fetch +const V1 = "/_matrix/client/v1"; // /relations lives only under v1 +const MAX_PENDING = 500; +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 +// not an automated notice. Threaded replies count; the ear delivers them to her. +export const movesRoom = (event, selfId) => isLine(event) && event.sender !== selfId && event.content?.msgtype !== "m.notice"; + +// A media event (m.image, m.file, m.audio, m.video) reaches the Persona as one typed line, then its caption if it has one: +// [image filename="cat.png" mime=image/png size=2048 url=mxc://gamecult.test/abc] +// A Persona turn is a tool-less text call (it has no way to open a file or see an image), so the mouth names the +// attachment and fetches nothing. Every field is checked before it is printed; a field that fails is left out. +// An encrypted attachment names its `file.url` and carries `encrypted`; its key is never read. `refused` is the +// fixed-word reason an image was not stored (persona-chat-images `refusals`); it is never sender text. +const MEDIA_KIND = { "m.image": "image", "m.file": "file", "m.audio": "audio", "m.video": "video" }; +const MXC = /^mxc:\/\/[A-Za-z0-9.:_-]+\/[A-Za-z0-9_-]+$/; +const MIME = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$/; +const FILENAME_BYTES = 255; +export function bodyText(content, maxBytes, refused = null) { + const kind = MEDIA_KIND[content?.msgtype]; + if (!kind) return docs.clip(content.body, maxBytes); + const encrypted = typeof content.file?.url === "string" && typeof content.url !== "string"; + const url = typeof content.url === "string" ? content.url : content.file?.url; + const named = typeof content.filename === "string" && content.filename !== ""; + const filename = named ? content.filename : content.body; + const parts = [kind, `filename=${JSON.stringify(docs.clip(filename, FILENAME_BYTES))}`]; + if (typeof content.info?.mimetype === "string" && MIME.test(content.info.mimetype)) parts.push(`mime=${content.info.mimetype}`); + if (Number.isSafeInteger(content.info?.size) && content.info.size >= 0) parts.push(`size=${content.info.size}`); + if (typeof url === "string" && MXC.test(url)) parts.push(`url=${url}`); + if (encrypted) parts.push("encrypted"); + if (refused !== null) parts.push(`refused=${refused}`); + const line = docs.clip(`[${parts.join(" ")}]`, maxBytes); + const caption = named && content.body !== filename ? content.body : ""; + return caption === "" ? line : docs.clip(`${line}\n${caption}`, maxBytes); +} + +// The one gate every mouth shares: run(roomId, fn) runs fn after every earlier fn on that room, and a +// failure of one does not stop the next. It holds ordering only, nothing across a restart. +export function createRoomGate() { + const tails = new Map(); + return { + run(roomId, fn) { + const result = (tails.get(roomId) ?? Promise.resolve()).then(fn); + const tail = result.catch(() => {}); + tails.set(roomId, tail); + tail.then(() => { if (tails.get(roomId) === tail) tails.delete(roomId); }); + return result; + }, + }; +} + +export function createMouth({ + persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, + now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS, + presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS, + follow = false, // the follow basis (CHAT_FOLLOW): 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)); + const roomIdByAlias = new Map(); + const aliasByRoomId = new Map(); + const joinedAliases = new Set(); + const freshRooms = new Set(); // joined by this process since the last sync: their history is context, not mail + const directRooms = new Set(); // room ids the account marks as DMs (m.direct) + const displayNames = new Map(); + let selfId = null; + let cursor = null; // the /sync position we are reading from + let seqCounter = 0; + let pending = []; // deliveries not yet acked + let batches = []; // { token, maxSeq }: positions awaiting an ack + let committed = null; // the position that is persisted + 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; + try { + response = await httpFetch(`${homeserver}${prefix}${path}`, { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + }); + } catch (error) { + throw new Error(`homeserver unreachable: ${redact(error?.message ?? String(error), token)}`); + } + if (!response.ok) throw Object.assign(new Error(`homeserver answered ${response.status} for ${method} ${path.split("?")[0]}`), { status: response.status }); + return response.json(); + } + + const released = async () => { try { return (await brake()) === true; } catch { return false; } }; + const noteDirect = content => { + for (const [user, ids] of Object.entries(content ?? {})) { + if (!Array.isArray(ids)) continue; + directContent[user] = ids.filter(id => typeof id === "string"); + directContent[user].forEach(id => directRooms.add(id)); + } + }; + + function noteInvites(invited) { + for (const [roomId, room] of Object.entries(invited ?? {})) { + const event = (room.invite_state?.events ?? []).find(e => e.type === "m.room.member" && e.state_key === selfId && typeof e.sender === "string"); + if (event) invites.set(roomId, { inviter: event.sender, direct: event.content?.is_direct === true }); + } + } + + // A DM invitation from an account on our own homeserver is accepted, and only while the brake is + // released; one received while braked waits for the release. An invitation from any other server is + // declined. A same-server invitation to a room that is not a DM is not ours to accept and is dropped. + async function answerInvites() { + for (const [roomId, invite] of [...invites]) { + try { + const room = encodeURIComponent(roomId); + if (!serverName || invite.inviter.slice(invite.inviter.indexOf(":") + 1) !== serverName) { + await matrix("POST", `/rooms/${room}/leave`, {}); + invites.delete(roomId); + } else if (!invite.direct) { + invites.delete(roomId); + } else { + if (!(await released())) continue; // read fresh before every join: a brake placed mid-pass stops the next one + await matrix("POST", `/join/${room}`, {}); + invites.delete(roomId); + directRooms.add(roomId); + directContent[invite.inviter] = [...new Set([...(directContent[invite.inviter] ?? []), roomId])]; + await matrix("PUT", `/user/${encodeURIComponent(selfId)}/account_data/m.direct`, directContent).catch(error => say(`m.direct not saved: ${error.message}`)); + } + } catch (error) { + say(`invite to ${roomId} not answered: ${error.message}`); + } + } + } + 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. + async function ensureRooms() { + const member = new Set((await matrix("GET", "/joined_rooms")).joined_rooms ?? []); + let free = null; + for (const alias of rooms.filter(a => !joinedAliases.has(a))) { + try { + let roomId = roomIdByAlias.get(alias); + if (!roomId) ({ room_id: roomId } = await matrix("GET", `/directory/room/${encodeURIComponent(alias)}`)); + roomIdByAlias.set(alias, roomId); + if (!member.has(roomId)) { + free ??= await released(); + if (!free) continue; + await matrix("POST", `/join/${encodeURIComponent(roomId)}`, {}); + freshRooms.add(roomId); + } + aliasByRoomId.set(roomId, alias); + joinedAliases.add(alias); + } catch (error) { + say(`room ${alias} not joined: ${error.message}`); + } + } + } + + async function start() { + if (!started) { + const saved = await state.load(); + cursor = committed = saved?.nextBatch ?? null; + seqCounter = saved?.seq ?? 0; + selfId = (await matrix("GET", "/account/whoami")).user_id; + try { noteDirect(await matrix("GET", `/user/${encodeURIComponent(selfId)}/account_data/m.direct`)); } catch { /* no m.direct: no DMs yet */ } + started = true; + } + if (unjoined()) await ensureRooms(); + } + + const threadRootOf = e => { const r = e?.content?.["m.relates_to"]; return r?.rel_type === "m.thread" && typeof r.event_id === "string" && r.event_id !== "" ? r.event_id : null; }; + 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. + // `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}`); + 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); + const root = await matrix("GET", `/rooms/${room}/event/${encodeURIComponent(rootId)}`); + const replies = await matrix("GET", `/rooms/${room}/relations/${encodeURIComponent(rootId)}/m.thread?dir=b&limit=${FETCH_LIMIT}`, undefined, V1); + return [root, ...(replies.chunk ?? []).reverse()].filter(isLine); + } + + // The context of one delivered event: the messages before it, the newest kept to the byte budget, each line clipped. + // `round` caches the fetches of one /sync round, so a room or thread is read once however many deliveries it made. + async function contextFor(roomId, event, round) { + const root = threadRootOf(event); + const key = root ? `${roomId}|${root}` : roomId; + if (!round.has(key)) { + round.set(key, (root ? threadEvents(roomId, root) : roomEvents(roomId)).catch(error => { + say(`context for ${roomId}${root ? ` thread ${root}` : ""} not fetched: ${error.message}`); + return []; + })); + } + const events = await round.get(key); + let cut = events.findIndex(e => e.event_id === event.event_id); + if (cut < 0) cut = events.filter(e => e.origin_server_ts < event.origin_server_ts).length; + 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; + bytes += size; + lines.unshift(l); + } + 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. + async function imageOf(roomId, content) { + if (!images || content?.msgtype !== "m.image") return {}; + if (content.file !== undefined) return { refused: "encrypted" }; + const got = await fetchImage({ fetch: httpFetch, homeserver, token, mxc: content.url }); + if (got.refusal) return { refused: got.refusal }; + try { return { image: images.put(roomId, got.bytes, got.mime) }; } catch (error) { + say(`image not stored: ${error?.code ?? "write failed"}`); + return { refused: "store-failed" }; + } + } + + // Her presence: the brake decides offline; otherwise a turn in the last presenceGraceMs decides online. + const presenceFor = free => !free ? "offline" : now() < activeUntil ? "online" : "unavailable"; + const touch = () => { activeUntil = now() + presenceGraceMs; }; + async function setPresence(free) { + let wanted = presenceFor(free); + if (wanted === lastPresence) return; + if (wanted !== "offline" && !(await released())) wanted = "offline"; // never PUT a present state on a stale brake read + if (wanted === lastPresence) return; + try { + await matrix("PUT", `/presence/${encodeURIComponent(selfId)}/status`, { presence: wanted }, API, AbortSignal.timeout(sendTimeoutMs)); + lastPresence = wanted; + } catch (error) { say(`presence not set: ${error.message}`); } + } + + // 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 + const filter = encodeURIComponent(JSON.stringify({ room: { timeline: { limit: CONTEXT } } })); + const since = cursor === null ? "" : `&since=${encodeURIComponent(cursor)}`; + const stateSent = presenceFor(free); // the state this poll carries, fixed before the poll: the grace may pass while it is open + const reply = await matrix("GET", `/sync?timeout=${cursor === null ? 0 : syncTimeoutMs}&set_presence=${stateSent}&filter=${filter}${since}`); + if (free) lastPresence = stateSent; // a braked round's offline is only believed once its own PUT lands + const baseline = cursor === null; // first ever sync: history is context, not mail + for (const event of reply.account_data?.events ?? []) if (event.type === "m.direct") noteDirect(event.content); + noteInvites(reply.rooms?.invite); + await answerInvites(); // each join re-reads the brake, as send does + const made = []; + const round = new Map(); + let truncated = false; // the pending cap was reached inside this round + rounds: for (const [roomId, room] of Object.entries(reply.rooms?.join ?? {})) { + const alias = aliasByRoomId.get(roomId) ?? (directRooms.has(roomId) ? roomId : undefined); + if (!alias) continue; + for (const event of [...(room.state?.events ?? []), ...(room.timeline?.events ?? [])]) { + 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 (!docs.isMatrixId("@", event.sender) || !docs.isMatrixId("$", event.event_id)) continue; // an id the tuple would refuse is no event: it could not be delivered, and one such event must not wedge the round + if (processed.has(event.event_id)) continue; // read already in a round the cap kept from advancing + const ts = event.origin_server_ts; + const atUnixMs = Number.isSafeInteger(ts) && ts >= 0 ? ts : now(); + const deliverable = !baseline && !freshRooms.has(roomId) && event.sender !== selfId; + if (deliverable && pending.length + made.length >= maxPending) { truncated = true; break rounds; } + if (deliverable && replyLink && replyReads + targetsToRead(roomId, event) > REPLY_READS_PER_ROUND) { say(`event ${event.event_id} not read: round cap on target reads, the rest of the round waits for the next`); truncated = true; break rounds; } // not processed: the next round meets it again, with its read count reset + if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event); + noteSpeaker(roomId, event); + let basis = deliverable ? await basisOf(roomId, event) : null; + if (deliverable && basis === null && await followed(roomId, event)) basis = "follow"; + const replyTo = deliverable ? await replyToOf(roomId, event) : null; + processed.add(event.event_id); + if (deliverable) { + const context = await contextFor(roomId, event, round); + const { image = null, refused = null } = await imageOf(roomId, event.content); + if (refused !== null) say(`image refused: ${refused}`); + const mentioned = event.content["m.mentions"]?.user_ids; + made.push({ + seq: ++seqCounter, roomAlias: alias, roomId, eventId: event.event_id, sender: event.sender, + 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 => 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); + } + } + } + pending.push(...made); + for (const d of made) { + delivered.set(d.eventId, { roomId: d.roomId, roomAlias: d.roomAlias, seq: d.seq }); + if (delivered.size > DELIVERED_INDEX) delivered.delete(delivered.keys().next().value); + } + if (!truncated) { // a truncated round leaves the position where it was: the rest stays on the server + cursor = reply.next_batch; + batches.push({ token: reply.next_batch, maxSeq: seqCounter }); + processed.clear(); + freshRooms.clear(); + } + await commit(); + if (made.length > 0) await state.save({ nextBatch: committed, seq: seqCounter }); // sequence numbers never repeat across a restart + return made; + } + + // The persisted position is the newest batch whose deliveries are all acked. + async function commit(throughSeq = pending.length ? pending[0].seq - 1 : seqCounter) { + let covered = null; + for (const b of batches) { if (b.maxSeq <= throughSeq) covered = b; else break; } + if (covered && covered.token !== committed) { + committed = covered.token; + await state.save({ nextBatch: committed, seq: seqCounter }); + } + batches = batches.filter(b => b.maxSeq > throughSeq); + } + + // Her m.read receipt, display-only: public, unthreaded, at the event Huginn reports she has taken in. Only for her own + // persona, an event this mouth delivered to her, in the room it was delivered for (the receipt goes to the delivered + // room id, never to the reported alias), and only forward of the last receipt that landed in that room. Reports for + // one room run one at a time, so the order they arrive in is the order they are judged in. Posted only while her + // brake is released, read fresh before the presence write and again before the POST. Returns the reason nothing + // was posted, or null. + function read({ persona: asked, roomAlias, eventId }) { + const quiet = message => { try { say(message); } catch { /* a throwing logger must not reject a caller that does not await */ } }; + if (asked !== persona) return Promise.resolve("PersonaMismatch"); + const known = delivered.get(eventId); + if (!known) { quiet("read report names an event this mouth never delivered"); return Promise.resolve("UnknownEvent"); } + if (roomAlias !== known.roomAlias && roomAlias !== known.roomId) { quiet("read report names another room than the delivery"); return Promise.resolve("RoomMismatch"); } + const run = async () => { + if ((readThrough.get(known.roomId)?.seq ?? 0) >= known.seq) return "Behind"; + if (!(await released())) { quiet("receipt not posted: brake held"); return "Brake"; } // none is queued + touch(); + await setPresence(true); + if (!(await released())) { quiet("receipt not posted: brake held"); return "Brake"; } + try { + await matrix("POST", `/rooms/${encodeURIComponent(known.roomId)}/receipt/m.read/${encodeURIComponent(eventId)}`, {}, API, AbortSignal.timeout(sendTimeoutMs)); + } catch (error) { + quiet(`receipt in ${known.roomId} not posted: ${error.message}`); + return "Failed"; // the last receipt that landed stays what it was: a later report for an older event is still Behind + } + readThrough.set(known.roomId, { seq: known.seq, eventId }); + return null; + }; + const tail = (readTails.get(known.roomId) ?? Promise.resolve()).then(run, run); // a report that threw must not wedge the room's later ones + readTails.set(known.roomId, tail); + tail.then(() => { if (readTails.get(known.roomId) === tail) readTails.delete(known.roomId); }, () => { if (readTails.get(known.roomId) === tail) readTails.delete(known.roomId); }); + return tail; + } + + async function ack(throughSeq) { + pending = pending.filter(d => d.seq > throughSeq); + images?.acked(throughSeq); + await commit(pending.length ? pending[0].seq - 1 : seqCounter); + 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 } })); + } + + // The ids of the messages that moved the room since `basis`, oldest first. Walks the head newest to oldest + // and stops at the event she saw; with none, at her turn's start time; if she saw an event the page does + // not reach, the whole page counts. + async function newerThan(roomId, basis) { + const filter = encodeURIComponent(JSON.stringify({ types: ["m.room.message"] })); + const reply = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/messages?dir=b&limit=${HEAD_PAGE}&filter=${filter}`, undefined, API, AbortSignal.timeout(sendTimeoutMs)); + const moved = []; + for (const e of reply.chunk ?? []) { + if (basis.seenEventId !== null ? e.event_id === basis.seenEventId : e.origin_server_ts <= basis.sinceUnixMs) break; + if (movesRoom(e, selfId)) moved.push(e.event_id); + } + return moved.reverse(); + } + + async function send({ requestId, persona: asked, roomAlias, text, basis }) { + const refuse = (refusal, newer = []) => ({ requestId, eventId: null, refusal, newer }); + if (asked !== persona) return refuse("PersonaMismatch"); + if (!(await released())) return refuse("Brake"); + if (typeof text !== "string" || text.trim() === "") return refuse("EmptyText"); + try { + if (!started || unjoined()) await start(); + const roomId = speakableRoomId(roomAlias); + if (!roomId) return refuse("UnknownRoom"); + const result = await gate.run(roomId, async () => { + if (basis) { + const newer = await newerThan(roomId, basis); + if (newer.length > 0) return refuse("RoomMoved", newer); + } + if (!(await released())) return refuse("Brake"); // a brake placed during the gate wait or the head read stops this send + 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 (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}`); + return refuse("SendFailed"); + } + } + + // 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) { + try { + await syncOnce(); + if (pending.length >= maxPending) await pause(1000); + } catch (error) { + say(`sync failed: ${error.message}`); + await pause(5000); + } + } + } + + // 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. +export function redact(message, secrets) { + let out = String(message); + for (const secret of [secrets].flat()) if (secret) out = out.split(secret).join("[redacted]"); + return out; +} + +// Run `fn`; whatever it throws leaves with every secret redacted. +export async function guarded(secrets, fn) { + try { return await fn(); } catch (error) { throw new Error(redact(error?.message ?? String(error), secrets)); } +} + +function localpart(userId) { + return userId.replace(/^@/, "").split(":")[0]; +} + +// Brake: released only when the instance's control store decodes to a released +// brake. Absent, undecodable or held reads are all "not released". +export function createBrakeReader({ controlPath, readStore, decode }) { + return async () => { + try { + const read = readStore(controlPath); + if (read.absent) return false; + const envelope = read.envelopes.find(e => e.type === BRAKE_TYPE && e.key === "brake"); + if (!envelope) return false; + const value = decode(new Uint8Array(envelope.payload)); + const released = Array.isArray(value) ? value[0] : value instanceof Map ? value.get(0) ?? value.get("released") : value?.[0] ?? value?.["0"] ?? value?.released; + return released === true; + } catch { + return false; + } + }; +} + +// The RUDP connection id is derived from the credential, so a peer without it cannot open the session. +export function connectionIdFor(connectionKey) { + return createHash("sha256").update(connectionKey).digest().readUInt32BE(0); +} + +// The reply to a chat_pending_request: deliveries oldest first, each encoded on its own so one that +// will not encode is skipped and logged without silencing the rest, up to the reply byte budget. +// The caller acks what it has read and asks again. +// A v4 reply (v4 true, with v3) is the v3 reply with each delivery's reply_to counted in its size, like any field: one oversize delivery is still alone on its page. +// A v3 reply also carries `tail` (room lines, oldest first), trimmed oldest first to what the byte budget has left after the deliveries, +// and `tail_gaps`: the rooms of `tail.rooms`, in that order, with lines missing from the reply: those `tail.gaps` names plus every room +// whose line the byte budget or an unencodable line dropped here. +export function pageOf(persona, deliveries, msgpack, log = () => {}, v3 = false, tail = { lines: [], gaps: [], rooms: [] }, v4 = false) { + const page = []; + let bytes = 0; + for (const delivery of deliveries) { + let tuple, size; + 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; + } + if (page.length > 0 && bytes + size > docs.limits.replyBytes) break; + page.push(tuple); + bytes += size; + } + 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). +// Every client (each Persona process and heartbeat dials with its own socket) gets its own +// 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 = () => {}, 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"); + await new Promise((ok, fail) => { socket.once("error", fail); socket.once("listening", ok); socket.bind(port, host); }); + const connectionId = connectionIdFor(connectionKey); + const server = new cultnet.CultNetRudpSocketTransportServer({ runtimeId, socket, connectionId, initialSequence: 100, resendDelayMs: 50, resendPollMs: 10, maxFragmentBytes: 1200, + // A client keeps its link between requests and does not redial, so only a client gone for an hour (a crash that sent no goodbye) is reaped. + peerTimeoutMs: 3_600_000 }); + 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, 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.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); + 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); + if (!mouth) return refuseUnknown(peer, request.persona); + await mouth.ack(request.throughSeq); + } else if (document.schemaId === docs.kinds.read) { + const request = docs.parseRead(tuple); + const mouth = mouths.get(request.persona); + if (!mouth) return refuseUnknown(peer, request.persona); + mouth.read(request).catch(error => log(`read report failed: ${error?.message ?? error}`)); // the peer's next request does not wait on Matrix + } else { + const v2 = document.schemaId === docs.kinds.sendRequestV2; + const request = v2 ? docs.parseSendRequestV2(tuple) : docs.parseSendRequest(tuple); + const mouth = mouths.get(request.persona); + const receipt = mouth ? await mouth.send(request) : { requestId: request.requestId, eventId: null, refusal: "UnknownPersona", newer: [] }; + if (v2) respond(peer, request.persona, docs.kinds.sendReceiptV2, receipt.requestId, docs.sendReceiptV2Tuple(receipt)); + else respond(peer, request.persona, docs.kinds.sendReceipt, receipt.requestId, docs.sendReceiptTuple(receipt)); + } + } catch (error) { + log(`chat endpoint rejected a frame: ${error?.message ?? error}`); + } + }; + // A peer's requests run in order; peers do not wait on one another. + const busy = new WeakMap(); + const frame = value => { busy.set(value.peer, (busy.get(value.peer) ?? Promise.resolve()).then(() => handle(value))); }; + server.on("frame", frame); + // A socket error is logged; the endpoint keeps serving. + server.on("error", error => log(`chat endpoint socket error: ${error?.message ?? error}`)); + return { address: socket.address(), close() { server.off("frame", frame); server.close(); } }; +} + +export function cultcacheState({ runtime, path }) { + const def = runtime.defineDocumentType({ + type: "gamecult.persona.chat_cursor.v1", schemaName: "gamecult.persona.chat_cursor.v1", schemaId: "gamecult.persona.chat_cursor.v1", + schemaVersion: "gamecult.persona.chat_cursor.v1", contentHash: "gamecult.persona.chat_cursor.v1", global: false, name: "key", + schema: { parse: v => ({ key: String(v.key), nextBatch: v.nextBatch ?? null, seq: Number(v.seq) }) }, + }); + const cache = runtime.CultCache.builder().withDocumentType(def).withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)).build(); + const unwrap = v => (Array.isArray(v) ? v[0] : v?.value ?? v); + return { + async load() { await cache.pullAllBackingStores(); return unwrap(cache.get(def, "cursor")) ?? null; }, + async save({ nextBatch, seq }) { await cache.put(def, "cursor", { key: "cursor", nextBatch, seq }); }, + }; +} + +export function loadRuntime(cultRoot) { + const at = name => createRequire(resolve(cultRoot, "packages", name, "package.json")); + const cc = at("cultcache-ts")("@gamecult/cultcache-ts"), cn = at("cultnet-ts")("@gamecult/cultnet-ts"), mp = at("cultnet-ts")("@msgpack/msgpack"); + 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]; +} +function credential(env, name) { + return readFileSync(join(required(env, "CREDENTIALS_DIRECTORY"), name), "utf8").trim(); +} + +const ROOM_ALIAS = /^#[^\s:,]+:[^\s,]+$/; + +// The env variable holding one Persona's project rooms: MATRIX_ROOMS_, the slug +// upper-cased with "." and "-" turned into "_". +export function projectRoomsVariable(persona) { + return `MATRIX_ROOMS_${persona.toUpperCase().replace(/[.-]/g, "_")}`; +} + +// One Persona's rooms: the shared MATRIX_ROOMS plus her own project rooms, deduplicated. +// Another Persona's project variable is never read. A malformed project alias is refused. +export function roomsFor(env, persona) { + const list = text => text.split(",").map(s => s.trim()).filter(Boolean); + const name = projectRoomsVariable(persona); + const project = list(env[name] ?? ""); + const bad = project.find(alias => !ROOM_ALIAS.test(alias)); + if (bad) throw new Error(`${name} has a malformed room alias: ${JSON.stringify(bad)} (expected #name:server)`); + return [...new Set([...list(required(env, "MATRIX_ROOMS")), ...project])]; +} + +// PERSONAS is the comma-separated list of Persona slugs this process speaks for. +// Each slug names its credential (matrix-token-), its cursor directory under +// STATE_DIRECTORY, its image store at IMAGE_STORE_ROOT/ (optional) and its brake at CONTROL_ROOT//control.cc. +export async function main(env = process.env) { + const personas = required(env, "PERSONAS").split(",").map(s => s.trim()).filter(Boolean); + if (personas.length === 0 || new Set(personas).size !== personas.length || !personas.every(p => SLUG.test(p))) throw new Error("PERSONAS must be distinct slugs"); + if (new Set(personas.map(projectRoomsVariable)).size !== personas.length) throw new Error("PERSONAS must map to distinct MATRIX_ROOMS_ variables"); + const tokens = new Map(personas.map(p => [p, credential(env, `matrix-token-${p}`)])); + const chatKey = credential(env, "chat-key"); + const secrets = [...tokens.values(), chatKey]; + return guarded(secrets, async () => { + const runtime = loadRuntime(resolve(env.CULTLIB_ROOT ?? resolve(import.meta.dirname, "..", "..", "CultLib"))); + const log = line => console.error(redact(line, secrets)); + const homeserver = required(env, "MATRIX_HOMESERVER").replace(/\/$/, ""); + const serverName = required(env, "MATRIX_SERVER_NAME"); + const roomsByPersona = new Map(personas.map(persona => [persona, roomsFor(env, persona)])); + const stateRoot = required(env, "STATE_DIRECTORY"); + 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, 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 }), + })]; + })); + 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, replyLink, fetchEnabled }); + await Promise.all([...mouths.values()].map(mouth => mouth.run())); + }); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error?.message ?? String(error)); process.exitCode = 1; }); +} 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" }, diff --git a/tools/sealed-motion-sign.mjs b/tools/sealed-motion-sign.mjs new file mode 100644 index 0000000..602162e --- /dev/null +++ b/tools/sealed-motion-sign.mjs @@ -0,0 +1,48 @@ +// Signing helper for a Mind's sealed-motion verbs. Reads the Mind's Ed25519 key file (32-byte seed, mode 0400/0600, owned by +// the running user: loadSignedIdentity checks all three), prints a signed envelope on stdout, and posts nothing. +// The output of `commit` also holds the ballot (candidate, reason, salt): keep it in the Mind's private state, never in a room. +// pubkey --key +// commit --key --persona --motion --voter --candidate --reason [--round 1] [--salt ] +// reveal --key --persona --motion --ballot '' +// Post an envelope with: curl -H 'Content-Type: application/json' -d @envelope.json https:///api/sealed-motions//mind +import { createHash, randomBytes, sign } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { loadSignedIdentity } from "./bifrost-signed-identity.mjs"; + +const DOMAIN = "gamecult.sealed_intake.v0"; +const BALLOT_SCHEMA = "gamecult.sealed_ballot.v0"; + +export function commitmentOf({ motion, voter, persona, round, candidate, reason, salt }) { + return createHash("sha256").update(`${BALLOT_SCHEMA}\n${motion}\n${voter}\n${persona}\n${round}\n${candidate}\n${reason}\n${salt}`, "utf8").digest("hex"); +} + +export function signEnvelope(identity, e) { + const text = [DOMAIN, e.persona, e.verb, e.motionId, e.nonce, e.issuedAt, e.commitment ?? "", e.round ?? 0, e.candidate ?? "", e.reason ?? "", e.salt ?? ""].join("\n"); + return { ...e, signature: sign(null, Buffer.from(text, "utf8"), identity.key).toString("base64") }; +} + +function args(argv) { + const out = {}; + for (let i = 1; i < argv.length; i += 2) out[argv[i].replace(/^--/, "")] = argv[i + 1]; + return out; +} + +async function main(argv) { + const [cmd] = argv, a = args(argv); + const identity = await loadSignedIdentity(a.key, "Mind sealed-motion key"); + if (cmd === "pubkey") return console.log(Buffer.from(identity.publicKey).toString("base64")); + const nonce = randomBytes(16).toString("base64url"), issuedAt = Math.floor(Date.now() / 1000); + if (cmd === "commit") { + const ballot = { round: Number(a.round ?? 1), candidate: a.candidate, reason: a.reason, salt: a.salt ?? randomBytes(16).toString("hex") }; + const commitment = commitmentOf({ motion: a.motion, voter: a.voter, persona: a.persona, ...ballot }); + const envelope = signEnvelope(identity, { persona: a.persona, verb: "commit", motionId: a.motion, nonce, issuedAt, commitment, round: ballot.round }); + return console.log(JSON.stringify({ envelope, ballot })); + } + if (cmd === "reveal") { + const b = JSON.parse(a.ballot).ballot ?? JSON.parse(a.ballot); + return console.log(JSON.stringify(signEnvelope(identity, { persona: a.persona, verb: "reveal", motionId: a.motion, nonce, issuedAt, round: b.round, candidate: b.candidate, reason: b.reason, salt: b.salt }))); + } + throw new Error("usage: pubkey | commit | reveal (see the header of this file)"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2)).catch(e => { console.error(e.message); process.exit(1); }); diff --git a/tools/watchdog-notice.mjs b/tools/watchdog-notice.mjs new file mode 100644 index 0000000..1e6dffc --- /dev/null +++ b/tools/watchdog-notice.mjs @@ -0,0 +1,299 @@ +#!/usr/bin/env node +// One-shot reader: turns Idunn's operator incidents into owner DMs. +// +// process --incident-store P --journal-store J --receipt-store R [--bridge-cli B] +// status --journal-store J +// +// Owner of the decision to send, the journal (J), the recipient +// (DISCORD_OWNER_ID) and the token (BIFROST_DISCORD_BOT_TOKEN, read only by the +// bridge it spawns). Idunn's store P is read unlocked and never written: no +// CultMesh node, no lock, only SingleFileMessagePackBackingStore.pullAll(). +// +// Requires CultLib 8cb3b728 or later (package @gamecult/cultcache-ts); see +// docs/watchdog-notice.md. +// +// Nothing here echoes an input value. Errors are fixed codes, and the +// journal's lastError is a fixed code, never bridge output. +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const cult = resolve(process.env.VOIDBOT_CULTLIB_ROOT || resolve(root, "..", "CultLib")); + +const INCIDENT_SCHEMA = "idunn.operator_incident.v1"; +const JOURNAL_TYPE = "bifrost.watchdog_notice_execution"; +const JOURNAL_SCHEMA = "bifrost.watchdog_notice_execution.v1"; +// A failed notice is retried forever: 1, 2, 4, ... minutes after its last +// attempt, capped at 60 (ruling notice-retry-policy). From FAILING_ATTEMPTS +// failures on, the exit status is the delivery-failure signal; retries go on. +const MAX_DELAY_MINUTES = 60; +const FAILING_ATTEMPTS = 3; +// The override exists so a test can reach the timeout branch without a 60 s wait. +const BRIDGE_TIMEOUT_MS = Number(process.env.WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS) || 60_000; +const STATUSES = ["running", "completed", "failed", "unknown"]; +const NAME = /^[a-z][a-z-]{0,63}$/; +// Idunn's require_id (GameCult/Idunn src/control_plane.rs:9122-9131 at 7f7528b): +// 1-256 bytes of [A-Za-z0-9-_.:/]. The notice puts the subject in inline code. +const SUBJECT = /^[A-Za-z0-9_.:/-]{1,256}$/; +const MAX_DATE_MS = 8.64e15; +// bifrost-bridge.mjs discord-dm exits 75 (EX_TEMPFAIL) for failures before +// the message POST and for any non-2xx answer to it: retried with the same nonce. +// Every other non-success leaves the outcome unknown. +const BRIDGE_NOT_SENT = 75; + +class NoticeError extends Error {} +const fail = (code) => new NoticeError(code); + +async function main(args) { + const [verb, ...rest] = args; + const options = parseArgs(rest); + if (verb === "status") return status(options); + if (verb === "process") return processIncidents(options); + throw fail("unknown-verb"); +} + +async function processIncidents(options) { + const incidentStore = requireOption(options, "incident-store"); + const journalStore = requireOption(options, "journal-store"); + const receiptStore = requireOption(options, "receipt-store"); + const bridgeCli = resolve(options["bridge-cli"] || resolve(root, "tools", "bifrost-bridge.mjs")); + const recipientId = (process.env.DISCORD_OWNER_ID ?? "").trim(); + if (!recipientId) throw fail("recipient-missing"); + + const runtime = loadRuntime(); + const { records, skipped, refused } = await readIncidents(runtime, incidentStore); + if (records === null) return 0; + + const journal = await openJournal(runtime, journalStore); + for (const entry of journal.cache.getAll(journal.definition)) { + if (entry.status === "running") { + await journal.put({ ...entry, status: "unknown", lastError: "interrupted-before-outcome", updatedAt: now() }); + } + } + + const deliver = async (record, notice) => { + const key = `${record.incidentKey}#${notice}`; + const previous = journal.cache.get(journal.definition, key); + const attempts = (previous?.attempts ?? 0) + 1; + const nonce = createHash("sha256").update(key).digest("hex").slice(0, 25); + const content = noticeText(record, notice); + const base = { schemaVersion: JOURNAL_SCHEMA, incidentKey: record.incidentKey, notice, attempts, nonce }; + await journal.put({ ...base, status: "running", messageId: "", lastError: "", updatedAt: now() }); + const outcome = await sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }); + await journal.put({ ...base, messageId: outcome.messageId ?? "", status: outcome.status, lastError: outcome.error ?? "", updatedAt: now() }); + }; + const retryable = (entry) => !entry || (entry.status === "failed" && Date.now() >= nextEligibleMs(entry)); + const entryFor = (record, notice) => journal.cache.get(journal.definition, `${record.incidentKey}#${notice}`); + + for (const record of records) { + if (retryable(entryFor(record, "opened"))) await deliver(record, "opened"); + if (record.closedAt !== null && entryFor(record, "opened")?.status === "completed" && retryable(entryFor(record, "closed"))) { + await deliver(record, "closed"); + } + } + + const entries = journal.cache.getAll(journal.definition); + const unknown = entries.filter((entry) => entry.status === "unknown").length; + const failing = entries.filter(isFailing).length; + process.stdout.write(`${JSON.stringify({ skipped, refused, unknown, failing })}\n`); + return unknown + failing + refused > 0 ? 1 : 0; +} + +async function status(options) { + const runtime = loadRuntime(); + const journal = await openJournal(runtime, requireOption(options, "journal-store")); + const entries = journal.cache.getAll(journal.definition); + const rows = entries + .map((entry) => ({ + incidentKey: entry.incidentKey, notice: entry.notice, status: entry.status, attempts: entry.attempts, + updatedAt: entry.updatedAt, lastError: entry.lastError, + nextEligibleAt: entry.status === "failed" ? new Date(nextEligibleMs(entry)).toISOString() : null, + })) + .sort((a, b) => (a.incidentKey + a.notice).localeCompare(b.incidentKey + b.notice)); + process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`); + return entries.some((entry) => entry.status === "unknown" || isFailing(entry)) ? 1 : 0; +} + +const isFailing = (entry) => entry.status === "failed" && entry.attempts >= FAILING_ATTEMPTS; +// Measured from the failed entry's last attempt in the journal. A stamp ahead of +// the clock is skew (a host clock stepped forward, then corrected), not a delay +// to wait out: that entry is eligible now, and its next attempt rewrites the stamp. +const nextEligibleMs = (entry) => { + const stamp = Date.parse(entry.updatedAt); + return stamp > Date.now() ? Date.now() : stamp + Math.min(2 ** (entry.attempts - 1), MAX_DELAY_MINUTES) * 60_000; +}; +const now = () => new Date(Date.now()).toISOString(); + +// Reads P unlocked. An absent file is "nothing to do" (records: null). A +// record of another schema is counted as skipped; a record of this schema +// that breaks the contract is counted as refused. Neither is echoed. +async function readIncidents(runtime, path) { + try { + await stat(path); + } catch (error) { + if (error?.code === "ENOENT") return { records: null, skipped: 0, refused: 0 }; + throw fail("incident-store-unreadable"); + } + let envelopes; + try { + envelopes = await new runtime.SingleFileMessagePackBackingStore(path).pullAll(); + } catch { + throw fail("incident-store-unreadable"); + } + const records = []; + let skipped = 0; + let refused = 0; + for (const envelope of envelopes) { + let tuple; + try { + tuple = runtime.decode(envelope.payload); + } catch { + skipped += 1; + continue; + } + if (!Array.isArray(tuple) || tuple[0] !== INCIDENT_SCHEMA) { + skipped += 1; + continue; + } + const record = parseIncident(tuple); + if (record) records.push(record); + else refused += 1; + } + records.sort((a, b) => a.openedAt - b.openedAt || a.incidentKey.localeCompare(b.incidentKey)); + return { records, skipped, refused }; +} + +// [schema_version, incident_key, condition, subject, opened_at, closed_at, close_reason] +function parseIncident(tuple) { + if (tuple.length !== 7) return null; + const [, incidentKey, condition, subject, openedAt, closedAt, closeReason] = tuple; + if (typeof condition !== "string" || !NAME.test(condition)) return null; + if (typeof subject !== "string" || !SUBJECT.test(subject)) return null; + if (!Number.isSafeInteger(openedAt) || openedAt <= 0 || openedAt > MAX_DATE_MS) return null; + if (incidentKey !== `${condition}:${subject}:${openedAt}`) return null; + if (closedAt === null) { + if (closeReason !== null) return null; + } else { + if (!Number.isSafeInteger(closedAt) || closedAt < openedAt || closedAt > MAX_DATE_MS) return null; + if (typeof closeReason !== "string" || !NAME.test(closeReason)) return null; + } + return { incidentKey, condition, subject, openedAt, closedAt, closeReason }; +} + +function noticeText(record, notice) { + const opened = new Date(record.openedAt).toISOString(); + if (notice === "opened") return `Idunn incident opened: ${record.condition} on \`${record.subject}\` at ${opened}`; + const closed = new Date(record.closedAt).toISOString(); + return `Idunn incident closed (${record.closeReason}): ${record.condition} on \`${record.subject}\`, opened ${opened}, closed ${closed}`; +} + +// The one send primitive. The outcome is completed with a message id; failed +// only when the bridge reports exit 75 (retryable, same nonce); otherwise unknown. The +// error is a fixed code: bridge stdout and stderr never reach the journal. +async function sendNotice({ bridgeCli, recipientId, receiptStore, key, nonce, record, content }) { + const dir = await mkdtemp(resolve(tmpdir(), "watchdog-notice-")); + try { + const contentFile = resolve(dir, "content.txt"); + await writeFile(contentFile, content, "utf8"); + const result = spawnSync(process.execPath, [ + bridgeCli, "discord-dm", + "--recipient-id", recipientId, + "--content-file", contentFile, + "--nonce", nonce, + "--cultmesh-command-id", key, + "--receipt-store", receiptStore, + "--source-kind", "idunn-operator-incident", + "--source-id", record.incidentKey, + ], { encoding: "utf8", env: process.env, windowsHide: true, timeout: BRIDGE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"] }); + if (result.status === BRIDGE_NOT_SENT) return { status: "failed", error: "bridge-not-sent" }; + if (result.error || result.status !== 0) return { status: "unknown", error: "bridge-exit-unknown" }; + try { + const value = JSON.parse(result.stdout); + if (value.action === "discord-dm" && value.ok === true && typeof value.messageId === "string" && value.messageId) { + return { status: "completed", messageId: value.messageId }; + } + } catch { + // falls through to the fixed code + } + return { status: "unknown", error: "bridge-output-invalid" }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function openJournal(runtime, path) { + const definition = runtime.defineDocumentType({ + type: JOURNAL_TYPE, + schemaName: JOURNAL_TYPE, + schemaId: JOURNAL_SCHEMA, + schemaVersion: JOURNAL_SCHEMA, + contentHash: JOURNAL_SCHEMA, + global: false, + schema: { parse: parseEntry }, + members: ["schemaVersion", "incidentKey", "notice", "status", "attempts", "nonce", "messageId", "lastError", "updatedAt"] + .map((memberName, slot) => ({ slot, memberName, typeName: memberName === "attempts" ? "uint32" : "string" })), + }); + const cache = runtime.CultCache.builder() + .withDocumentType(definition) + .withGenericStore(new runtime.SingleFileMessagePackBackingStore(path)) + .build(); + try { + await cache.pullAllBackingStores(); + } catch { + throw fail("journal-unreadable"); + } + return { cache, definition, put: (entry) => cache.put(definition, `${entry.incidentKey}#${entry.notice}`, entry) }; +} + +function parseEntry(value) { + const ok = value && value.schemaVersion === JOURNAL_SCHEMA + && typeof value.incidentKey === "string" && value.incidentKey + && (value.notice === "opened" || value.notice === "closed") + && STATUSES.includes(value.status) + && Number.isSafeInteger(value.attempts) && value.attempts >= 0 + && ["nonce", "messageId", "lastError", "updatedAt"].every((field) => typeof value[field] === "string") + && Number.isFinite(Date.parse(value.updatedAt)); + if (!ok) throw fail("journal-entry-invalid"); + return value; +} + +function loadRuntime() { + try { + const cache = createRequire(resolve(cult, "packages", "cultcache-ts", "package.json")); + const net = createRequire(resolve(cult, "packages", "cultnet-ts", "package.json")); + const cc = cache("@gamecult/cultcache-ts"); + return { + CultCache: cc.CultCache, + SingleFileMessagePackBackingStore: cc.SingleFileMessagePackBackingStore, + defineDocumentType: cc.defineDocumentType, + decode: net("@msgpack/msgpack").decode, + }; + } catch { + throw fail("cultlib-unavailable"); + } +} + +function parseArgs(args) { + const options = {}; + for (let i = 0; i < args.length; i += 2) { + if (!args[i].startsWith("--") || args[i + 1] === undefined) throw fail("bad-arguments"); + options[args[i].slice(2)] = args[i + 1]; + } + return options; +} + +function requireOption(options, name) { + if (!options[name]) throw fail(`missing-option-${name}`); + return options[name]; +} + +try { + process.exitCode = await main(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`watchdog-notice: ${error instanceof NoticeError ? error.message : "unexpected-error"}\n`); + process.exitCode = 1; +}