Shelf: reveal to table — player-safe layer, multi-surface (#245) #265

Merged
claude-bot merged 2 commits from feat/v3.11-reveal-to-table into main 2026-07-27 18:41:01 +00:00
Contributor

Closes #245.

A shelf card is GM-only. Revealing it shows the card's player-safe layer on one or more surfaces and records what was shown.

Scoped per the discussion on approach: Discord and a "show to players" surface for logged-in members (players already have Quest Board logins, so this needs no public/unauthenticated route), with the dispatch built as a registry so the Foundry pillar can plug in later.

The player-safe layer

New shelf_cards.player_text — what the table may see, as against the rest of the card, which stays GM-only.

When the GM hasn't authored one, the server derives a conservative default:

  • wiki cards → the entry's public summary, never gm_notes
  • generation cards → read-aloud fields only (read_aloud, one_liner, paragraph, flavor)

That derivation is a strict allowlist, not a denylist: an unknown tool's output contributes nothing rather than leaking whatever it happens to call its secret. When it comes out empty the reveal is refused (422) rather than guessed at — the safe failure mode for a redaction feature.

Whatever does get shown is saved back onto the card, so the GM sees exactly what the table saw and can edit it, instead of revealing blind on every showing.

Why reveals are rows, not a flag

shelf_reveals exists as its own table because:

  • The shown text is frozen. Later GM edits to the card never retroactively rewrite a handout the players have already read.
  • A card can be shown more than once — a re-read, an updated notice — and each showing is its own row.
  • The reveal outlives the card. shelf_card_id is ON DELETE SET NULL, so discarding a card in the end-of-session sweep (#247) doesn't erase the record of what the table was shown.

revoked_at takes a reveal back down without deleting the history of it having been shown.

Surfaces are a registry

_REVEAL_DISPATCH = {
    REVEAL_TARGET_PLAYERS: _dispatch_players,   # the row itself is the surface
    REVEAL_TARGET_DISCORD: _dispatch_discord,   # additive bot-stream event
}

Adding a surface — Foundry "push into the live scene" for v4.x — is one entry here plus one value in REVEAL_TARGETS. No schema change, no migration, no new column.

Dispatch runs before the row is persisted and is all-or-nothing, so a surface that refuses (Discord with no channel configured) never leaves behind a reveal claiming the table saw something it didn't.

Authorization

Reading reveals is open to every campaign member — it is the players' surface, via get_session_for_member. Revealing and revoking stay GM-only. The board itself remains GM-only and is unchanged.

Cross-component

shelf_reveal is an additive event on the durable bot-event stream, so BOT_CONTRACT_VERSION stays 1 (it governs the /api/bot/* shape, not the event vocabulary). Backend and bot move in this one commit per the monorepo rule. The bot posts player_text verbatim, never enriches it from another source, and never logs the revealed text.

Frontend

  • Reveal composer on each card: editable player text + surface checkboxes. It deliberately does not derive the safe default client-side — blank asks the server — so the redaction rule lives in one place rather than two that can drift. A 422 stays inside the modal next to the box that fixes it.
  • /sessions/:id/table — the players' mirror of the shelf, linked from SessionDetail for every member. Polls rather than streams; a failed poll leaves the last good list on screen instead of replacing the table's reference with an error mid-scene.

Migration

d9e0f1a2b3c4 (head, from c8d9e0f1a2b3). Applied cleanly against a full chain replay from empty.

Testing

All run locally in containers matching the CI jobs:

  • Backend: 790 passed; 13 new in test_shelf_reveal.py covering the explicit/derived/refused text paths, gm_notes and secret never leaking, the target registry (including the unknown-target and failed-dispatch-reveals-nothing cases), the member-read/GM-write boundary, revoke idempotency, frozen text, and the reveal surviving its card.
  • Bot: 194 passed; 2 new covering verbatim posting and the empty-player_text no-op.
  • Frontend: 421 passed + production build; 5 new reveal cases and 3 for the table view.
  • ruff check / ruff format --check clean; eslint clean (the one warning is pre-existing in CampaignDetail.jsx).

🤖 Generated with Claude Code

Closes #245. A shelf card is GM-only. Revealing it shows the card's **player-safe layer** on one or more surfaces and records what was shown. Scoped per the discussion on approach: Discord *and* a "show to players" surface for logged-in members (players already have Quest Board logins, so this needs no public/unauthenticated route), with the dispatch built as a registry so the Foundry pillar can plug in later. ## The player-safe layer New `shelf_cards.player_text` — what the table may see, as against the rest of the card, which stays GM-only. When the GM hasn't authored one, the server derives a conservative default: - **wiki cards** → the entry's public `summary`, **never** `gm_notes` - **generation cards** → read-aloud fields only (`read_aloud`, `one_liner`, `paragraph`, `flavor`) That derivation is a strict **allowlist, not a denylist**: an unknown tool's output contributes nothing rather than leaking whatever it happens to call its secret. When it comes out empty the reveal is **refused** (422) rather than guessed at — the safe failure mode for a redaction feature. Whatever does get shown is saved back onto the card, so the GM sees exactly what the table saw and can edit it, instead of revealing blind on every showing. ## Why reveals are rows, not a flag `shelf_reveals` exists as its own table because: - **The shown text is frozen.** Later GM edits to the card never retroactively rewrite a handout the players have already read. - **A card can be shown more than once** — a re-read, an updated notice — and each showing is its own row. - **The reveal outlives the card.** `shelf_card_id` is `ON DELETE SET NULL`, so discarding a card in the end-of-session sweep (#247) doesn't erase the record of what the table was shown. `revoked_at` takes a reveal back down without deleting the history of it having been shown. ## Surfaces are a registry ```python _REVEAL_DISPATCH = { REVEAL_TARGET_PLAYERS: _dispatch_players, # the row itself is the surface REVEAL_TARGET_DISCORD: _dispatch_discord, # additive bot-stream event } ``` Adding a surface — **Foundry "push into the live scene"** for v4.x — is one entry here plus one value in `REVEAL_TARGETS`. No schema change, no migration, no new column. Dispatch runs **before** the row is persisted and is **all-or-nothing**, so a surface that refuses (Discord with no channel configured) never leaves behind a reveal claiming the table saw something it didn't. ## Authorization Reading reveals is open to **every campaign member** — it is the players' surface, via `get_session_for_member`. Revealing and revoking stay GM-only. The board itself remains GM-only and is unchanged. ## Cross-component `shelf_reveal` is an **additive** event on the durable bot-event stream, so **`BOT_CONTRACT_VERSION` stays 1** (it governs the `/api/bot/*` shape, not the event vocabulary). Backend and bot move in this one commit per the monorepo rule. The bot posts `player_text` verbatim, never enriches it from another source, and never logs the revealed text. ## Frontend - **Reveal composer** on each card: editable player text + surface checkboxes. It deliberately does *not* derive the safe default client-side — blank asks the server — so the redaction rule lives in one place rather than two that can drift. A 422 stays inside the modal next to the box that fixes it. - **`/sessions/:id/table`** — the players' mirror of the shelf, linked from SessionDetail for every member. Polls rather than streams; a failed poll leaves the last good list on screen instead of replacing the table's reference with an error mid-scene. ## Migration `d9e0f1a2b3c4` (head, from `c8d9e0f1a2b3`). Applied cleanly against a full chain replay from empty. ## Testing All run locally in containers matching the CI jobs: - Backend: **790 passed**; 13 new in `test_shelf_reveal.py` covering the explicit/derived/refused text paths, `gm_notes` and `secret` never leaking, the target registry (including the unknown-target and failed-dispatch-reveals-nothing cases), the member-read/GM-write boundary, revoke idempotency, frozen text, and the reveal surviving its card. - Bot: **194 passed**; 2 new covering verbatim posting and the empty-`player_text` no-op. - Frontend: **421 passed** + production build; 5 new reveal cases and 3 for the table view. - `ruff check` / `ruff format --check` clean; `eslint` clean (the one warning is pre-existing in `CampaignDetail.jsx`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A shelf card is GM-only. Revealing it shows the card's *player-safe layer* on
one or more surfaces and records what was shown.

The player-safe layer is a new `shelf_cards.player_text`. When the GM hasn't
authored one, the server derives a conservative default: the wiki entry's public
summary (never `gm_notes`), or a generation card's read-aloud fields only. That
derivation is a strict allowlist rather than a denylist — an unknown tool's
output contributes nothing instead of leaking whatever it happens to call its
secret — and when it comes out empty the reveal is refused rather than guessed
at. Whatever does get shown is saved back onto the card, so the GM sees exactly
what the table saw and can edit it, rather than revealing blind each time.

Reveals are their own rows, not a flag on the card, because the shown text must
be frozen against later GM edits, a card can be shown more than once, and the
record has to outlive the card being discarded in the sweep (#247).

Surfaces are a registry, not a branch — `_REVEAL_DISPATCH` maps a name to a
dispatcher — so the Foundry pillar (v4.x) can add "push into the live scene" as
one entry plus one vocabulary value, with no schema change. Dispatch runs before
the row is persisted and is all-or-nothing, so a surface that refuses (Discord
with no channel configured) never leaves behind a reveal claiming the table saw
something it didn't.

Reading reveals is open to every campaign member — it is the players' surface —
while revealing and revoking stay GM-only. The Discord surface is an additive
event on the durable bot-event stream, so BOT_CONTRACT_VERSION stays 1; the bot
posts `player_text` verbatim and never enriches it from another source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(frontend): reveal control + the players' table view (#245)
All checks were successful
CI / Docker image build (pull_request) Successful in 24s
CI / Backend lint (ruff) (pull_request) Successful in 47s
CI / Frontend tests, audit, and build (pull_request) Successful in 1m50s
CI / Bot tests and audit (pull_request) Successful in 2m2s
CI / Backend migration, tests, and audit (pull_request) Successful in 5m34s
43143d16c1
Adds an eye control to each shelf card that opens a reveal composer, and a new
`/sessions/:id/table` page — the players' mirror of the shelf, showing only what
has been revealed and only its player-safe layer.

The composer deliberately does not derive the player-safe text client-side:
leaving the box blank asks the server for its conservative default, so the
redaction rule lives in exactly one place rather than in two that can drift.
Since a reveal saves the shown text back onto the card, reopening the composer
pre-fills with what the table actually saw. A 422 (nothing safe to show, or a
surface that can't be dispatched to) stays inside the modal next to the box that
fixes it, rather than becoming a page-level error.

The table view is reachable by every campaign member, not just the GM, so it is
linked from SessionDetail unconditionally — it is the one session surface a
player can open. It polls rather than streams: revealing is a deliberate,
low-frequency act, and a failed poll leaves the last good list on screen instead
of replacing the table's reference with an error mid-scene.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/v3.11-reveal-to-table 2026-07-27 18:41:02 +00:00
Sign in to join this conversation.
No description provided.