Skip to content

Add POST /arena/vote write endpoint - #150

Merged
seribaymadina merged 1 commit into
mainfrom
arena/05-vote-endpoint-clean
Jun 22, 2026
Merged

Add POST /arena/vote write endpoint#150
seribaymadina merged 1 commit into
mainfrom
arena/05-vote-endpoint-clean

Conversation

@seribaymadina

@seribaymadina seribaymadina commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Stacks on the merged read endpoints (#147) to complete the arena vote loop's write side.

What

POST /v1/arena/vote records a vote on a battle.

  • Labeler-gated via an X-Labeler-Key header (constant-time compare against ARENA_LABELER_KEY). A valid key votes as labeler; missing/invalid key → 403 ("external voting is not enabled"). Fail-closed: if no key is configured, all votes are rejected.
  • voter_type is pinned server-side, never read from the body — a caller cannot claim to be a trusted labeler.
  • Dedup via ArenaStore.upsert_vote: ON CONFLICT (battle_id, voter_type, voter_id) DO UPDATE keeps one current vote per identity (re-vote updates the row; the BEFORE UPDATE trigger bumps updated_at).
  • Unknown battle → 404; invalid outcome → 422; 60/minute rate limit, matching the read endpoints.
  • PostHog arena_vote_cast carries only {outcome, voter_type} — no identifiers, consistent with the other arena events.

Scope (MVP, labeler-only)

  • Public external voting is intentionally not enabled yet — it needs a stable per-voter identity (session) for the dedup to be meaningful, plus its own rate strategy. Deferred to the voting-UI PR.
  • voter_id is not validated; acceptable while the only caller is the trusted internal labeling tool.

Note

ARENA_LABELER_KEY must be set as a runner secret (Cloud Run / Secret Manager) before this works in a deployed env, or every vote returns 403.

Summary by CodeRabbit

Release Notes

  • New Features

    • Labelers can now cast and update votes on arena battles through an authenticated endpoint using a labeler key
    • Votes are deduplicated and tracked with timestamps for audit purposes
  • Tests

    • Comprehensive test coverage added for voting functionality, authorization, and validation

Greptile Summary

This PR completes the arena vote write path by adding POST /v1/arena/vote, gated behind an X-Labeler-Key header verified with a constant-time compare and defaulting to 403 when unconfigured. Dedup is handled via an ON CONFLICT DO UPDATE upsert, and voter_type is pinned server-side so callers can never self-promote to labeler.

  • Auth (_is_authenticated_labeler): uses hmac.compare_digest against a SecretStr; the same 403 message is returned for a missing or wrong key, leaking no information about which condition was triggered.
  • Write path: ArenaStore.upsert_vote performs a single INSERT … ON CONFLICT DO UPDATE RETURNING, with the battle-existence pre-check handled by a separate get_battle query; the PostHog event carries only {outcome, voter_type} with no voter identifiers.
  • Tests: 9 integration tests cover all documented paths — auth failures, happy path, identity-injection guard, dedup/re-vote, multi-voter rows, empty voter_id, unknown battle, and invalid outcome.

Confidence Score: 4/5

The write path is well-structured and the auth logic is correct; the two minor concerns do not affect normal operation.

The auth guard, dedup logic, and test coverage are solid. The two flagged items — a TOCTOU window between the battle existence check and the upsert, and duplicate outcome definitions across VoteIn and VoteOutcome — are both benign under current usage (battles are not deleted, the two sets are identical), but either could surface as a 500 in a future change rather than a clean 404 or 422.

runner/src/coval_bench/api/routers/arena.py around the two-query battle check and the VoteOutcome conversion; runner/src/coval_bench/api/schemas.py for the loose str types in VoteOut.

Important Files Changed

Filename Overview
runner/src/coval_bench/api/routers/arena.py Adds POST /v1/arena/vote with labeler-key auth (hmac.compare_digest, fail-closed), server-side voter_type pinning, and upsert-based dedup; two minor concerns: TOCTOU between the get_battle check and upsert_vote, and duplicate outcome definitions between VoteIn and VoteOutcome.
runner/src/coval_bench/api/schemas.py Adds VoteIn and VoteOut Pydantic schemas; VoteOut uses plain str for outcome/voter_type rather than Literal types, losing the precision already established in VoteIn and the DB CHECK constraint.
runner/src/coval_bench/config.py Adds arena_labeler_key: SecretStr
runner/tests/api/conftest.py Adds ARENA_LABELER_KEY constant and injects it via monkeypatch for the app fixture; straightforward test scaffolding.
runner/tests/api/test_arena.py Adds 9 focused vote-endpoint tests covering auth failures, happy path, voter_type injection guard, dedup, multi-voter, empty voter_id, unknown battle, and invalid outcome; schema setup includes the votes table, trigger function, and FK constraint.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant R as POST /v1/arena/vote
    participant Auth as _is_authenticated_labeler
    participant Store as ArenaStore
    participant DB as PostgreSQL

    C->>R: "POST /v1/arena/vote {battle_id, outcome, voter_id} X-Labeler-Key"
    R->>Auth: check(x_labeler_key, settings)
    Auth-->>R: hmac.compare_digest result
    alt key missing or invalid
        R-->>C: 403 external voting is not enabled
    else key valid
        R->>Store: get_battle(battle_id)
        Store->>DB: "SELECT arena.battles WHERE id = ?"
        DB-->>Store: row or None
        alt battle not found
            R-->>C: 404 battle not found
        else battle exists
            R->>Store: upsert_vote(battle_id, outcome, LABELER, voter_id)
            Store->>DB: INSERT ON CONFLICT DO UPDATE RETURNING
            DB-->>Store: Vote row
            Store-->>R: Vote model
            R->>R: capture_api_event arena_vote_cast
            R-->>C: 201 VoteOut
        end
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant R as POST /v1/arena/vote
    participant Auth as _is_authenticated_labeler
    participant Store as ArenaStore
    participant DB as PostgreSQL

    C->>R: "POST /v1/arena/vote {battle_id, outcome, voter_id} X-Labeler-Key"
    R->>Auth: check(x_labeler_key, settings)
    Auth-->>R: hmac.compare_digest result
    alt key missing or invalid
        R-->>C: 403 external voting is not enabled
    else key valid
        R->>Store: get_battle(battle_id)
        Store->>DB: "SELECT arena.battles WHERE id = ?"
        DB-->>Store: row or None
        alt battle not found
            R-->>C: 404 battle not found
        else battle exists
            R->>Store: upsert_vote(battle_id, outcome, LABELER, voter_id)
            Store->>DB: INSERT ON CONFLICT DO UPDATE RETURNING
            DB-->>Store: Vote row
            Store-->>R: Vote model
            R->>R: capture_api_event arena_vote_cast
            R-->>C: 201 VoteOut
        end
    end
Loading

Reviews (1): Last reviewed commit: "Add POST /arena/vote write endpoint" | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Labeler-gated via X-Labeler-Key; voter_type is pinned server-side so a
caller cannot claim to be a labeler. Records votes through ArenaStore,
which upserts to keep one vote per identity per battle.
@vercel

vercel Bot commented Jun 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
benchmarks Ready Ready Preview, Comment Jun 20, 2026 12:08am

Request Review

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a POST /v1/arena/vote endpoint to the arena router with HMAC-based labeler authentication via an X-Labeler-Key header. Introduces VoteIn/VoteOut Pydantic schemas, an optional arena_labeler_key setting in Settings, and test infrastructure including the arena.votes table schema and comprehensive endpoint tests.

Changes

Arena Labeler Vote Endpoint

Layer / File(s) Summary
Config key and vote schemas
runner/src/coval_bench/config.py, runner/src/coval_bench/api/schemas.py
Settings gains optional arena_labeler_key: SecretStr | None; VoteIn and VoteOut Pydantic models are added with battle/outcome/voter identity fields and timestamps.
Router imports, auth helper, and vote endpoint
runner/src/coval_bench/api/routers/arena.py
Router imports are extended with hmac, get_settings, ArenaStore, VoteOutcome, and VoterType; _is_authenticated_labeler performs constant-time key comparison; POST /arena/vote enforces auth (403), battle existence (404), upserts via ArenaStore, emits a Posthog event, and returns VoteOut with status 201.
Test fixtures, schema, and vote tests
runner/tests/api/conftest.py, runner/tests/api/test_arena.py
ARENA_LABELER_KEY constant and env-var injection are added to conftest; _apply_arena_schema is extended with arena.votes DDL and an updated_at trigger; new tests cover 403/404/422 cases, successful persistence, voter_type pinning, revote dedup, distinct-voter rows, empty voter_id, and unknown battle handling.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ArenaRouter as POST /arena/vote
  participant ArenaStore
  participant Posthog

  Client->>ArenaRouter: request + X-Labeler-Key header + VoteIn body
  ArenaRouter->>ArenaRouter: _is_authenticated_labeler (hmac.compare_digest)
  alt key mismatch or missing
    ArenaRouter-->>Client: 403 Forbidden
  end
  ArenaRouter->>ArenaStore: get_battle(battle_id)
  alt battle not found
    ArenaRouter-->>Client: 404 Not Found
  end
  ArenaRouter->>ArenaStore: upsert_vote(VoterType.LABELER, VoteOutcome, voter_id)
  ArenaStore-->>ArenaRouter: persisted Vote row
  ArenaRouter->>Posthog: capture("vote_cast", {...})
  ArenaRouter-->>Client: 201 VoteOut
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • coval-ai/benchmarks#119: Introduced the arena.battles table migration that arena.votes references via foreign key in this PR's test schema setup.
  • coval-ai/benchmarks#138: Introduced ArenaStore.upsert_vote, VoteOutcome, and VoterType that the new cast_vote endpoint directly calls.
  • coval-ai/benchmarks#147: Added the read-only /v1/arena/* endpoints in the same router module that this PR extends with the vote write path.

Suggested reviewers

  • coval-cale

Poem

🐇 A labeler knocks with a secret key in hand,
The arena checks the header — constant-time, as planned.
A battle is found, a vote upserted true,
Posthog gets pinged, and a 201 shines through.
No voter_type tricks — the server holds the pen,
This bunny hops happy, votes are safe again! 🗳️

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a new POST endpoint for arena voting.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch arena/05-vote-endpoint-clean

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@seribaymadina
seribaymadina requested a review from coval-cale June 20, 2026 00:09
Comment thread runner/src/coval_bench/api/schemas.py
Comment thread runner/src/coval_bench/api/routers/arena.py
Comment thread runner/src/coval_bench/api/routers/arena.py
@seribaymadina
seribaymadina removed the request for review from coval-cale June 22, 2026 17:56
@seribaymadina
seribaymadina added this pull request to the merge queue Jun 22, 2026
Merged via the queue into main with commit 00e164e Jun 22, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants