# Route Spec

## Route ID
`posts-vote-create`

## Endpoint
`POST /api/v1/posts/{postId}/vote`

## Human Description
Casts the current user's one-time 5-position slider vote into anonymous post and hub vote pools. Only posts with `slider` are votable.

The successful vote also stores aggregate-only milestone subscription/work state
in the same transaction. Fanout runs asynchronously: creator every 100 global
votes, participant every additional 500 votes after their post-vote baseline.
No per-vote notification or per-participant scan is performed in this request.
See [the shared milestone contract](../../slider-notification-milestones.md).

## Authentication
- Required: `yes`
- Auth type: `bearer token`
- Registered public-profile basics are required.
- Phone confirmation and contact access are not required for public voting, including public Hub-originated content viewed in Public.

## Request
### Headers
- `Content-Type: application/json`

### Body
```json
{
  "position": "left"
}
```

### Validation Rules
- `position`: required enum `strong_left|left|neutral|right|strong_right`.
- Viewer must be allowed to see the post. For `private_locked_hub`, the viewer must be the author or an active member of at least one direct or onward-share target Hub.
- User must not already have a `post_vote_receipts` row for this post.
- Permanent Hub scopes are calculated for every active membership where the post is already targeted or shared. Shareable slider posts also prepare temporary anonymous totals for the Hub-ready voter's other current Hubs. There is no minimum Hub size.
- A public or `public_hub` post receives its one-time global vote and every eligible Hub scope vote. A `private_locked_hub` post returns `VOTE_NOT_ALLOWED` only when the voter has no eligible active-member Hub scope.

## Responses
### Success: `201 Created`
```json
{
  "success": true,
  "message": "Vote cast",
  "data": {
    "postId": "pst_1",
    "summary": {
      "totalVotes": 123,
      "resultsVisible": true,
      "leftCount": 78,
      "neutralCount": 12,
      "rightCount": 33
    }
  }
}
```

### Error: `403 Forbidden`
```json
{"success": false, "error": {"code": "VOTE_NOT_ALLOWED", "message": "You cannot vote on this post.", "details": {}}}
```

When returned:
- The post is private Hub-only content and the voter is not Hub-ready or is not an active member of any Hub where the post is visible.

### Error: `401 Unauthorized`
When returned:
- Missing or invalid access token.

Body:
```json
{"success": false, "error": {"code": "UNAUTHORIZED", "message": "Authentication required.", "details": {}}}
```

### Error: `404 Not Found`
When returned:
- Post does not exist.

Body:
```json
{"success": false, "error": {"code": "POST_NOT_FOUND", "message": "Post does not exist.", "details": {}}}
```

### Error: `409 Conflict`
When returned:
- User already voted on this post frame.
- The post has no slider.

Body:
```json
{"success": false, "error": {"code": "VOTE_ALREADY_CAST", "message": "You already voted on this post.", "details": {}}}
```

Alternative body when the post has no slider:
```json
{"success": false, "error": {"code": "POST_NOT_VOTABLE", "message": "Post does not have a slider.", "details": {}}}
```

### Error: `422 Unprocessable Entity`
When returned:
- Invalid `position` value.

Body:
```json
{"success": false, "error": {"code": "VALIDATION_FAILED", "message": "Please fix highlighted fields.", "details": {}}}
```

## Data & Caching Dependencies
- **Spanner Tables:** `post_vote_receipts, post_vote_scope_receipts, post_feed_activity_events, notifications (Write), post_vote_counters, post_vote_bucket_counters, post_hub_vote_counters, post_hub_vote_bucket_counters, post_hub_pending_vote_counters (Update/Insert), post_hub_targets, post_share_targets, hub_memberships (Read)`
- **Redis Backplane:** Publishes committed `post_voted` notification IDs to SSE.
- **Firebase:** Attempts FCM delivery for each committed eligible notification.
- **GCS Storage:** `None`
- **Edge Cache (CDN):** `No`

## Side Effects
- Inserts `post_vote_receipts(post_id, user_id, voted_at)` without the selected position.
- Computes eligible hub scopes at vote time from current post hub targets/shares and active hub memberships.
- Inserts `post_vote_scope_receipts` rows for the global scope and each eligible hub scope without the selected position.
- Increments the global post anonymous vote counters and detailed bucket counters.
- Increments anonymous hub vote counters and detailed bucket counters only for eligible hubs at vote time.
- For shareable posts, increments hidden hourly five-position totals for other current Hubs, without user IDs or pending scope receipts. Their deadline is the database casting hour plus 337 hours. Receipt insertion, membership snapshot, and all counter writes use one serializable transaction.
- Creates `post_voted` notification for the post owner when voter is not owner, then publishes/attempts push only after commit.
- Records a `post_voted` feed activity event for Home feed friend-interaction discovery.

## Visibility Rules
- The response never returns the user's selected position.
- Aggregate bucket counts in `summary` are visible from the first vote.
- Sharing to an eligible current Hub before its pending deadline promotes the anonymous totals permanently. Later joins and votes without surviving pending totals cannot be reconstructed. Expiry never deletes the global receipt or permits a second vote.

## Repost Notes
- Votes apply to the current post frame's slider.
- If the target post is a repost, voting uses the repost's independent slider and anonymous vote pools, not the original source post's slider.
- Reposts without top-level `sliderText`/`slider` are not votable even if their `sourcePost` has `sliderText` or `slider`.
