Lane 7, PR 3 (server): table rules and safety, anonymous for real (#733, #121) #831

Merged
claude-bot merged 2 commits from build/lane7-p3-safety into main 2026-09-26 10:08:45 +00:00
Collaborator

This is the server side of Table rules & safety: lines, veils, and anonymous suggestions and concerns. It builds the survey's recommended answers T3(a), T4(a) and T11(a) provisionally; the owner answers them in the morning. Each answer lives in one named place, listed below.

The model (migration 7e5afe7a1b2c on d4e6f8a0b2c3)

  • safety_entries: lines and veils, with sort order and soft delete for Undo.
  • safety_submissions: id, campaign_id, kind (line|veil|unsure|concern), text, session_id, submitted_on DATE. No user column and no timestamp.
  • ck_notifications_kind gains safety_waiting, which is GM-only, sent once per unread, and marked read when the queue empties.

The API (under /api/campaigns/{cid}/safety)

  • Reading: GET for any member; the waiting queue is null for players.
  • The quiet count (T4): GET /safety/waiting returns {waiting}, GM-only.
  • Entries: CRUD with restore, GM-only.
  • Submitting: POST /safety/submissions, any member. It returns only {"sent": true}. It's rate-limited, and a session_id is allowed only on a concern.
  • Handling: accept turns a submission into an entry and deletes it; DELETE dismisses it. There's no expiry timer (T11).
  • Export and import: schema "12" carries the standing entries. The queue is never exported.

Anonymity, proven by tests

  • The database:
    • The schema has no user foreign key and no time-typed column, checked in the ORM, the live table and the migration's SQL.
    • After submitting, a whole-database scan finds the sender's id, name and email in no new row.
    • notify never gets an actor_id.
  • Logs:
    • The app's own: the endpoint clears the request's user_id context, so no log record carries the sender. The survey missed this leak.
    • uvicorn's access log leaves out the submission path.
    • slowapi's rate-limit warning also named the client IP and the path. It's now filtered, a second leak the survey missed.
    • nginx: access_log off and error_log /dev/null for that path. docs/OPERATIONS.md has nginx and Caddy examples and says Traefik can't filter by path.
  • Verified by hand: nginx -t; nginx in front of a dummy backend (the POST wasn't logged, a GET was); and real uvicorn.
  • What remains: the GM's bell row has the minute it arrived (T4(a) accepts that), and Redis session TTLs could be correlated by someone with Redis access.

Tests

  • 64 new: test_safety.py (41), test_safety_anonymity.py (16) and test_safety_migration.py (7).
  • 454 targeted tests pass after the rebase onto #828.
  • The migration goes up, down and up.
  • ruff is clean. No bot change.

🤖 Generated with Claude Code

This is the server side of Table rules & safety: lines, veils, and anonymous suggestions and concerns. It builds the survey's recommended answers T3(a), T4(a) and T11(a) provisionally; the owner answers them in the morning. Each answer lives in one named place, listed below. **The model** (migration `7e5afe7a1b2c` on `d4e6f8a0b2c3`) - **`safety_entries`:** lines and veils, with sort order and soft delete for Undo. - **`safety_submissions`:** `id, campaign_id, kind (line|veil|unsure|concern), text, session_id, submitted_on DATE`. **No user column and no timestamp.** - **`ck_notifications_kind`** gains `safety_waiting`, which is GM-only, sent once per unread, and marked read when the queue empties. **The API** (under `/api/campaigns/{cid}/safety`) - **Reading:** `GET` for any member; the waiting queue is null for players. - **The quiet count (T4):** `GET /safety/waiting` returns `{waiting}`, GM-only. - **Entries:** CRUD with restore, GM-only. - **Submitting:** `POST /safety/submissions`, any member. It returns only `{"sent": true}`. It's rate-limited, and a `session_id` is allowed only on a concern. - **Handling:** `accept` turns a submission into an entry and deletes it; `DELETE` dismisses it. There's no expiry timer (T11). - **Export and import:** schema "12" carries the standing entries. The queue is never exported. **Anonymity, proven by tests** - **The database:** - The schema has no user foreign key and no time-typed column, checked in the ORM, the live table and the migration's SQL. - After submitting, a whole-database scan finds the sender's id, name and email in no new row. - `notify` never gets an `actor_id`. - **Logs:** - **The app's own:** the endpoint clears the request's `user_id` context, so no log record carries the sender. The survey missed this leak. - **uvicorn's access log** leaves out the submission path. - **slowapi's rate-limit warning** also named the client IP and the path. It's now filtered, a second leak the survey missed. - **nginx:** `access_log off` and `error_log /dev/null` for that path. `docs/OPERATIONS.md` has nginx and Caddy examples and says Traefik can't filter by path. - **Verified by hand:** `nginx -t`; nginx in front of a dummy backend (the POST wasn't logged, a GET was); and real uvicorn. - **What remains:** the GM's bell row has the minute it arrived (T4(a) accepts that), and Redis session TTLs could be correlated by someone with Redis access. **Tests** - 64 new: `test_safety.py` (41), `test_safety_anonymity.py` (16) and `test_safety_migration.py` (7). - 454 targeted tests pass after the rebase onto #828. - The migration goes up, down and up. - ruff is clean. No bot change. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Lane 7, server PR 3. A campaign's lines and veils, read by everyone and
written by its GMs (with Remove and its undo), and one anonymous queue any
member can send a suggestion, a "not sure" or a flagged concern to.

Anonymity is held by the shape, not by care:
- safety_submissions has no user column and no timestamp: only the day
  (in the campaign's timezone, T3), ordered by random id within it;
- the submitting request clears the log context's user_id, and the
  service that writes the row is never given the sender;
- its path is dropped from uvicorn's access log and slowapi's rate-limit
  warning (logging_config.UNLOGGED_PATHS) and has access_log/error_log off
  in the frontend nginx; OPERATIONS.md tells operators to do the same;
- safety_waiting goes to every GM with no actor, and says nothing of what
  was sent; the response is {"sent": true} and nothing else.
test_safety_anonymity proves each (schema, a whole-database trace scan,
notify's arguments, in-request log records with a control, uvicorn and
slowapi filters with a control, nginx config).

T4(a): the bell is told at once, and GET .../safety/waiting is the bare
count for Table mode. T11(a): nothing expires a submission. Export (schema
12) carries table_rules and never the queue; import restores them.
Migration 7e5afe7a1b2c on d4e6f8a0b2c3 (#828); the one-head pin moves with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into build/lane7-p3-safety
All checks were successful
CI / Backend lint (ruff) (pull_request) Successful in 32s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m54s
CI / Bot/backend version sync (pull_request) Successful in 48s
CI / Feedback site tests (pull_request) Successful in 1m26s
CI / Docker image build (pull_request) Successful in 27s
CI / Does this change touch the recording path? (pull_request) Successful in 27s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Has been skipped
CI / Bot tests and audit (pull_request) Successful in 3m57s
CI / Backend migration, tests, and audit (pull_request) Successful in 14m32s
CI / Frontend tests, audit, and build (pull_request) Successful in 24m20s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 15m24s
d943b0c6ee
# Conflicts:
#	CHANGELOG.md
Sign in to join this conversation.
No description provided.