# API Conventions

## Base
- Base path: `/api/v1`
- Primary format: JSON (`application/json`)
- Time format: ISO-8601 UTC
- Pagination: cursor-based (`cursor`, `limit`)

## Envelope
### Success envelope
```json
{
  "success": true,
  "message": "Human message",
  "data": {}
}
```

## Error Code Governance
- Canonical code list: `docs/api/ERROR_CODES.md`
- All API errors must use one of the cataloged machine codes.
- Route docs must include explicit `### Error` sections (at least one expected business error + one auth/permission error for private routes).
- `openapi-strict.yaml` is the enforcement source for typed error code values.

### Error envelope
```json
{
  "success": false,
  "error": {
    "code": "MACHINE_CODE",
    "message": "Human message",
    "details": {}
  }
}
```

## Identifier and Path Rules
- `userId`, `postId`, `hubId`, `commentId`, `reportId`, `assetId`, `uploadId` are immutable system IDs.
- `username` is mutable user handle and never used where immutable identity is required.
- User-facing copy calls `username` a handle and decorates it with `@`; API and database
  values never include `@`.
- Canonical handles are lowercase ASCII `a-z`, `0-9`, and `_`, 3-30 characters. Name
  derivation/transliteration is an editable client convenience; the backend canonical
  policy and the unique database index remain authoritative.
- Availability previews do not reserve handles. Changed and deleted-account handles are
  reusable immediately after their database update commits.

### Path standardization
- Current user scope: `/users/me/...`
- Immutable-identity scope: `/users/{userId}/...`
- Username lookup scope: `/users/by-username/{username}`
- Resource identity by ID only for all non-user resources: `/posts/{postId}`, `/hubs/{hubId}`, etc.

## Auth Rules
- Auth mechanism: Bearer access token.
- Token refresh: `POST /auth/session/refresh`.
- Public routes explicitly marked in route files.
- Opening the app may create a backend anonymous session before any phone verification. Anonymous sessions are real backend users and receive normal bearer access/refresh tokens.
- Level 1, anonymous: may use public reads, type drafts locally, open public links, report, and block. Public participation and authoring mutations require a higher level even though the anonymous session has a valid bearer token.
- Level 2, registered public profile: completed profile basics are required for voting, public comments, follow/unfollow, save/unsave, feed sharing, native sharing, and public-profile management. Phone confirmation is not required.
- Level 3, phone-confirmed: verified phone identity plus completed profile setup is required for public post and repost creation. Contact access is not required for General, public-profile, or personal-feed authoring.
- Level 4, Hub-ready: verified phone identity, completed profile setup, and a non-null `contacts_synced_at` acknowledgement are required for actual Hub surfaces/actions. Active membership and role checks remain additional requirements.
- Public or `public_hub` content shown in General continues to use the lower public gate for viewing, voting, commenting, saving, and sharing. Entering or acting in its actual Hub context requires level 4.
- Phone verification can merge eligible anonymous public activity into the verified phone identity.
- Contact synchronization is a separate one-time Hub-readiness acknowledgement. The API never receives the full contact book; only phone numbers explicitly selected for an invitation action are sent.

## Upload Rules (Signed URL Only)
- Binary uploads never pass through app server.
- Flow:
  1. `POST /uploads/presign`
  2. Client `PUT` to GCS signed URL
  3. `POST /uploads/complete`
- Upload intent TTL: 15 minutes.

## Vote Rules (Slider)
- A vote is cast once into anonymous aggregate pools. The request position is used only during the write transaction; user-linked storage never keeps the chosen position.
- `POST /posts/{postId}/vote` accepts one of five positions: `strong_left`, `left`, `neutral`, `right`, `strong_right`.
- A user can vote only once per post frame. A repeat vote returns `VOTE_ALREADY_CAST`; vote editing is not supported because the old position is not stored.
- The system stores user-linked vote receipts without vote value:
  - `post_vote_receipts` proves the user voted on the post frame.
  - `post_vote_scope_receipts` records which global/hub scopes the vote counted in, without the selected position.
- Summary views group the five positions into three buckets:
  - `leftCount` = `strong_left + left`
  - `neutralCount` = `neutral`
  - `rightCount` = `right + strong_right`
- Detailed distribution views expose all five positions.
- At vote time, the backend increments the global post pool and every eligible hub pool where the post is already visible and the voter is already an active member. There is no minimum Hub size.
- Hub eligibility is evaluated only at vote time. Later hub joins, later shares of the post into a hub, and Hub scopes omitted by the superseded minimum-member policy do not backfill old votes because the system no longer knows the user's selected position.
- Hub analytics are scoped views of the same post frame's poll, not separate polls.
- Aggregate results are visible from the first vote in each global or Hub scope.
- A zero-vote scope returns `resultsVisible=false`, `totalVotes=0`, `null` summary bucket counts, and an empty detailed distribution.
- Feed/detail vote summaries use the currently displayed scope.
- The current user's vote status remains visible through `GET /posts/{postId}/vote/me`, but the response never returns the selected position.
- Post card responses include `viewerVote` with only `hasVoted` and `votedAt`.
- When `viewerVote.hasVoted=true` on a slider card, post card responses may include `voteDistribution` so clients can render the detailed breakdown from the feed/detail payload without a separate distribution request. This is aggregate-only data and does not reveal the viewer's selected position.

## Post Content and Repost Rules
- A post can contain optional `article`, optional `thoughtText`, and optional `sliderText`/`slider`. At least one of `article`, `thoughtText`, or `sliderText` is required.
- `article` is optional reusable article/link/media/text content.
- `thoughtText` is the author-specific thought/take for this post or repost.
- `sliderText` is the visible slider title/prompt, capped at 260 characters. There is no separate `sliderTitle` field.
- The `slider` object contains only the left/right labels and uses the fixed 5-position scale. `slider` is present only when `sliderText` is present.
- Posts without a `slider` are not votable. Vote endpoints return `POST_NOT_VOTABLE` for non-slider posts.
- Original post creation may create a reusable article record and always creates a post frame.
- Repost creation creates a new post frame with new `thoughtText` and/or new `sliderText`/`slider`. Reposts cannot include a new `article`.
- A repost must include new display content from the reposter: `thoughtText` and/or `sliderText`. An empty repost body is a share concept, not a repost.
- Repost cards include `sourcePost`, which points to the original post and includes the original post's display content (`article`, `thoughtText`, `sliderText`, and `slider`) for source rendering.
- Reposting a repost is not allowed.
- Private locked hub posts cannot be reposted.
- Repost count is tracked on the original source post and counts active repost posts only.
- Share-to-feed is separate from repost. Use `POST /posts/{postId}/share/feed` for a no-new-content feed share of the exact post/repost frame. Use `POST /posts/{postId}/reposts` only when the user adds a new thought and/or slider around an original.
- Feed-share is repeatable and creates a share event; it does not create a new post frame, poll, thought, slider text, slider, or repost count.
- Share-to-hub is separate from repost. Share-to-hub places an existing post into a hub without creating a new post frame, poll, thought, slider text, or slider.
- External share is separate from repost and uses the native phone share sheet with `GET /posts/{postId}/share-link`.
- `GET /posts/{postId}/frames` returns visible original/repost frames for the same source lineage: the original post plus direct reposts whose `sourcePostId` points to that original.

## Post Visibility Rules
- `visibility=public`: appears in public/profile/follower feeds according to normal feed ranking. It is repostable unless blocked by other policy.
- `visibility=public_hub`: appears publicly like `public` and also appears in selected hub feeds/member feeds. It is repostable unless blocked by other policy.
- `visibility=private_locked_hub`: appears only in selected hub feeds and member feeds for active members of those hubs.
- Private locked hub posts do not appear in the creator's public profile feed and do not appear to followers outside the selected hubs.
- Private locked hub posts cannot be reposted, shared to another hub, or exposed through a public share link.
- Private locked hub post detail requires the viewer to be the author or an active member of at least one selected hub; otherwise the post is hidden.

## Home Feed Rules
- The Home feed is `GET /posts`.
- Guests and authenticated users with no personal feed sources receive public posts from admin-managed default feed users.
- Registered public-profile users may receive personalized public candidates from:
  - Current user's own public and public-hub posts.
  - Public and public-hub posts from followed users.
  - Public and public-hub posts that followed users interacted with by voting, commenting, reposting, or sharing to their feed.
- Hub-ready users may additionally receive:
  - Public and public-hub posts from active co-members in at least one hub the viewer belongs to.
  - Posts targeted to hubs where the viewer is an active member, including private locked hub posts for those selected hubs.
  - Public and public-hub posts that active hub co-members interacted with by voting, commenting, reposting, or sharing to their feed.
  - `post_shared_to_hub` friend-interaction candidates only when the viewer has an active membership in that exact target Hub.
- A hub co-member is a user who shares at least one active hub membership with the viewer.
- Friend-interaction discovery uses followed users plus active hub co-members as feed actors.
- Only `post_shared_to_hub` activity is Hub-scoped for friend-interaction ranking. Public votes, comments, reposts, and General/feed shares remain global. Eligibility uses current active membership (`hub_memberships.deleted_at IS NULL`) at feed-read time. A Hub-target activity is authorized only when the viewer is Hub-ready and an active member of that exact target Hub. Feed-target activity remains global. New joiners may see historical authorized Hub shares at their original event time; leavers lose that feed activity immediately.
- `private_locked_hub` content must never leak through friend-interaction discovery. It can appear in Home only when the viewer is the author or an active member of at least one selected hub for that post.
- Unauthorized Hub-share activity must not bump, merge, rank, or page a post for that viewer.
- Feed cards may include `feedContext` to explain why a post appears: `own_post`, `followed_author`, `hub_co_member_author`, `joined_hub_post`, `friend_interaction`, `shared_to_hub`, or `default_feed_user`.

## Search Rules
- Global people discovery: `GET /search/users`
  - Public-field-only results: `id`, `name`, `username`, `profilePhotoUrl`, `bio`.
  - Phone numbers are never returned from global people search.
  - Optional auth may add viewer relationship fields such as `viewerFollows`.
- Global hub discovery: `GET /search/hubs`
- Local list search: `q` query on list endpoints (followers/following/members)

## Hub Invitation Rules
- There is no direct active hub join endpoint.
- `hub_memberships` represents active members only.
- Admin-created phone invitations become active membership when the invited user accepts.
- Member-created phone invitations become `pending_admin_approval` when the invited user accepts; an admin must approve before membership is active.
- Hub invite-link joins always create `pending_admin_approval`; links never create active membership directly.
- Hub invite expiry defaults to 7 days.
- The server does not send SMS. It returns invite URLs/share text so the mobile app can use the device's native SMS/share flow.
- Backend invite APIs receive only phone numbers explicitly selected for that invite action, never the full contact book.

## Hub Discussion Rules
- Public post comments use `GET/POST /posts/{postId}/comments`.
- Hub discussion is separate from public comments.
- A hub discussion is private to active members of one hub and tied to one post inside that hub.
- Public comments always project `UserLite.username` as the public handle, including `public_hub` content viewed in General. The generic public-comment routes reject `private_locked_hub` posts even for members; those posts use the Hub discussion route.
- Hub discussion messages and saved-discussion previews use the dedicated `HubDiscussionAuthor` shape, not `UserLite`. For an active non-anonymous author it exposes `phoneNumber` and leaves `publicUsername` null. A former member has no phone projection and may fall back to `publicUsername`; anonymous history exposes neither field nor a stable id.
- Phone projection requires both an active viewer membership (enforced by the route) and an active author membership in that same Hub. Invited, pending, removed, and unrelated users cannot obtain it.
- Public comments and hub discussion messages support one-level reply threads via `parentCommentId`/`parentMessageId`; there are no replies to replies. Replying to a reply attaches the new item to the thread's root, and list responses include `replyCount` per top-level item.
- Hub discussion message activity updates hub/discussion last-activity ordering.
- Hub discussion messages do not create notification events.

## Saved Content Rules
- Saved hub discussions are saved only from hub discussion/chat context.
- Saved discussion items point to a hub discussion, not to the standalone post.
- Creating/removing a saved discussion requires active membership in that hub.
- Saved-discussion list responses omit items from hubs where the user no longer has active membership.
- Saved discussion cards may include up to three earliest chat messages as preview context.
- The mobile saved-post/bookmark tab is separate from the saved hub discussion endpoint. Public saved-post actions require registered profile basics but must not open phone verification.

## Notifications
- Pull model: `GET /notifications`
- Mark read: `PATCH /notifications/read`
- Realtime stream: `GET /notifications/stream` (SSE)
- A notification event means an in-app notification row and SSE candidate. Push, app-icon badge, and hub footer badge delivery are controlled by notification settings and mute rules.
- Hub discussion/chat messages never create notification events. They only update hub/discussion last-activity ordering.
- Hub mute is per-user and per-hub. `contentMuted=true` suppresses `hub_post_created`, `post_shared_to_hub`, and Hub-scoped `post_reposted` for that user/hub. Global `post_reposted` is not muted. Mute does not change feed content or ranking.
- Suppressed hub content activity creates no in-app notification feed row for the muted user and produces no notifications stream event, push, app-icon badge, or hub footer badge for that muted user.
- Hub mute does not suppress invitations, join requests, approvals/rejections, member joined/removed, role changes, or non-hub notification events.
- Publication alerts are durable in-app rows plus live SSE only. They do not send APNs/FCM/OS push.
- A notification with a non-null `hub_id` is visible on list, unread count, mark-read, and SSE only when the viewer is Hub-ready and an active member of that exact Hub at authorization/emission time. Historical unauthorized rows are retained, not deleted or backfilled, and are hidden dynamically. Leavers lose visibility; rejoining can reveal retained rows. New joiners are not backfilled with notifications.
- Legacy `post_reposted` rows may have `hub_id=null` for a referenced `private_locked_hub` repost. Those rows are visible only when the viewer is Hub-ready and an active member of at least one current `post_hub_targets` Hub for that private repost. A null-hub `post_reposted` row that references a known public/General/`public_hub` post stays global. Null `post_id`, missing posts, and unknown or malformed visibility fail closed.
- Hidden mark-one-read returns the existing `{ updated: 0 }` shape and must not change unread state. Mark-all updates only currently visible rows.
- SSE rechecks live database authorization at emission time and does not use a stale membership cache. Malformed Hub-scoped rows fail closed.
- Non-hub/social notification event matrix:

| Event | Notification Type | Recipients |
|---|---|---|
| User votes on a post | `post_voted` | Post owner, when voter is not owner |
| User comments on a post | `post_commented` | Post owner, when commenter is not owner |
| User reposts a post with General enabled or `public_hub` | `post_reposted` | Original source author, when reposter is not author. Exactly one global row (`hub_id` null). Not muted by Hub content mute. |
| User reposts a post as `private_locked_hub` | `post_reposted` | Original source author, when reposter is not author, one Hub-scoped row per target Hub in which the source author is currently an active member. Honors Hub content mute. No global `post_reposted`. |
| User shares a post to their feed | `post_shared_to_feed` | Exact target post owner, when sharer is not owner |
| User follows another user | `user_followed` | Followed user |
| User saves a hub discussion | None | None |
| User reports content/user/hub | None | None; moderation workflow is internal |
| User blocks another user | None | None |

- Hub activity notification event matrix:

| Event | Notification Type | Recipients |
|---|---|---|
| New original post/article is created inside a hub | `hub_post_created` | Active target hub members, excluding actor. One durable row per eligible Hub/member. |
| Existing post is shared into a hub | `post_shared_to_hub` | Active target hub members, excluding actor. One durable row per eligible target Hub/member. Original authors outside the target Hub do not receive an extra share notification. |
| A repost is published into a hub | `post_shared_to_hub` | Active target hub members, excluding actor and excluding the original source author. The source author receives `post_reposted` instead, using the source-author rules above. |
| Hub invitation is created for an existing user | `hub_invite_received` | Invited user |
| Invite-link join request is created | `hub_join_request_created` | Hub admins/managers |
| Member-created phone invitation is accepted and needs approval | `hub_join_request_created` | Hub admins/managers |
| Join request is approved | `hub_join_request_approved`, `hub_member_joined` | Requester, plus active hub members for the join event |
| Join request is rejected | `hub_join_request_rejected` | Requester |
| Admin-created invitation is accepted | `hub_member_joined` | Active hub members, excluding joining user |
| Member is removed from a hub | `hub_member_removed` | Removed member |
| Hub role is changed | `hub_role_changed` | Target member |
| Hub discussion/chat message is created | None | None |

## Privacy Rules
- **Public Profile**: `GET /users/by-username/{username}` never returns phone numbers.
- **Hub Member Context**: Phone numbers are private by default and may be returned only from hub member/admin contexts that require the viewer to be an approved member of that hub.
- **Hub Discussion Identity**: Active members reading a Hub discussion see each active non-anonymous message author's verified phone number. General/public comments continue to expose only the public username. The two API author shapes must remain structurally distinct.
- **Dark Hub Preview**: Invited or pending-approval users may see safe hub/member preview fields, but never phone numbers or private interaction surfaces.
- **Authentication Is Not Enough**: A logged-in user cannot see another user's private hub profile data or phone number unless both users share an approved hub context.
