# 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 Public, 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 Public 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 acknowledgement creates a pending snapshot upload but never waits for it; names, normalized phone numbers, and available thumbnails are sent through the separate contact-sync upload route.

## Upload Rules (Signed URL Only)
- Media asset uploads never pass through the app server. The contact-sync route is the narrow exception: it accepts bounded, device-provided contact thumbnails inside its isolated snapshot payload.
- 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 cards may include nullable/omitted top-level `authorPhoneNumber` only for the
  exact `isShareable=false` single-private-Hub frame. Projection requires a
  Hub-ready viewer with active membership in the sole direct target Hub and a
  non-anonymous author who remains active in that same Hub with a verified phone.
  It is never part of the reusable public `author` or `sourcePost` shapes.

## 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.
- `sourcePost.isShareable` is an actionable, server-authorized source capability.
  It requires both intrinsic source shareability and current viewer visibility of
  the source. A public repost remains independently shareable when this source
  capability is false for a viewer outside the private source Hubs.
- Reposting a repost is not allowed.
- A Public-off post targeted to exactly one private Hub cannot be reposted; a private post targeted to two or more Hubs can 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 direct target Hub feeds and onward-share target Hub feeds/member feeds for active members of those Hubs.
- Private locked hub posts do not appear merely because a viewer follows the author. A private feed share can appear on the sharer's profile or in Home only when the viewer is independently authorized through a direct or onward Hub target.
- Shareability is destination-count based: any Public destination is shareable; with Public off, exactly one private Hub target is locked and two or more private Hub targets are shareable again.
- Private locked hub post detail requires the viewer to be the author or an active member of at least one direct or onward-share target Hub; otherwise the post is hidden.

## Profile Feed Rules
- The profile feed is `GET /users/{userId}/feed` and has the same source policy for the owner's own view and every other viewer.
- It contains qualifying original posts and reposts authored by the profile owner, including authored `public_hub` frames.
- It also contains non-anonymous `post_share_targets` rows created by that owner with `target_type=feed`, which is the explicit mobile "My Feed" choice.
- A `target_type=hub` share never adds, bumps, merges, ranks, or pages a profile card, regardless of the viewer's Hub readiness or membership. It remains available to authorized Home and Hub feed selection.
- Feed sharing does not grant source visibility. A private feed-shared frame is returned only when the viewer is independently authorized through a direct or onward Hub target.
- Each exact post/repost frame appears once. A later explicit feed share can bump that existing profile card; a Hub share cannot.

## 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 directly targeted or shared onward to Hubs where the viewer is an active member, including private locked Hub posts for those 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 Public/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 direct or onward-share target 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 Public. 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)
- Device registration: Level-2/profile-basics-gated `POST /notifications/device-tokens` and authenticated `DELETE /notifications/device-tokens` for Android/iOS FCM tokens. Registration retains at most five tokens per user and tokens expire from eligibility after 60 days without refresh.
- A successfully committed eligible notification atomically creates one durable in-app row and one delivery-outbox row. The primary API worker publishes Redis/SSE and FCM/OS-push hints outside the mutation request with bounded leases/retries; the app refreshes the durable inbox after SSE, push, startup, foreground resume, and reconnect, and app-icon/in-app badges use the returned authoritative unread count.
- Mobile notification delivery is gated by the current identity revision's backend-authoritative `profileBasics=true` onboarding state, not by whether its phone/provider identity remains anonymous. This admits Level-2 registered-without-phone users, rejects Level-1 anonymous sessions, starts once per ready auth revision, and retries a failed readiness load on app resume. Inbox refreshes are serialized and burst-coalesced. Failed refreshes and device-token registrations use bounded 1-30 second cooldowns, Android resume transitions are debounced for one second, and unchanged successful FCM tokens are refreshed every 24 hours so active installations remain well inside the 60-day eligibility window. Repeated identical badge counts do not produce repeated launcher writes; an unsupported/failing launcher badge implementation is disabled for that app process without affecting the durable inbox.
- 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.
- Eligible publication alerts use the same durable-inbox, SSE, FCM/OS-push, foreground-local-notification, and badge path as other notification types.
- A notification with a non-null `hub_id` is visible on list, unread count, mark-read, SSE, and push 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. Recipient-only `hub_invite_received`, `hub_join_request_rejected`, and `hub_member_removed` rows have narrow Hub-ready exceptions because those events occur without an active recipient membership; every other row for that Hub remains hidden.
- 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`/`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.
- An explicit syntactically valid ID absent from the current user's owned rows returns `404 NOTIFICATION_NOT_FOUND`; this does not reveal whether it belongs to another user.
- SSE rechecks live database authorization at emission time and does not use a stale membership cache. Malformed Hub-scoped rows fail closed. The mobile client reconnects after the Cloud Run one-hour timeout with bounded backoff and reconciles with `GET /notifications` so Redis/SSE gaps cannot lose durable events.
- Push and SSE are recoverable hints, may be delayed or duplicated, and are never the source of truth. Clients deduplicate by notification ID. Android tags and APNs collapse IDs use that ID. Confirmed permanently invalid FCM registrations are deleted, generic payload/config errors do not delete tokens, token refresh is re-registered, and globally unique token ownership is reassigned on account changes. Logout and account lifecycle paths explicitly clean server tokens; mobile logout also invalidates its Firebase token.
- Anonymous votes return an actor object with empty identity fields and `isAnonymous=true`; clients render “Someone” and never attempt to expose the voter identity.
- 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 Public 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}` and `GET /users/{userId}/profile` never return phone numbers. The stable-ID lookup accepts an optional bearer for viewer relationship fields and therefore returns `Cache-Control: private, no-cache, no-store` with `Vary: Authorization`.
- **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. Public comments continue to expose only the public username. The two API author shapes must remain structurally distinct.
- **Locked Post Card Identity**: An authorized active member reading an exact
  non-shareable single-Hub frame may receive top-level `authorPhoneNumber` for an
  author who is still active in that Hub. Public/shareable cards, profile/search
  identity payloads, expanded source cards, former authors, and anonymous,
  invited, pending, removed, or unrelated viewers never receive the value.
- **Post Card Caching**: Endpoints returning post cards set
  `Cache-Control: private, no-cache, no-store` and `Vary: Authorization`; private
  author phone values must not be logged or used in cache keys.
- **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.
