Samples / September 19, 2026
Original Idea

"I want to build a photo and video sharing app like Instagram. People from all over the world will be uploading lots of pictures and videos, so it needs to load really fast and handle a ton of users without crashing."

Chroma

Target: Claude Code & GKE Category: Consumer App Downstream: Agentic SDD
D
David

Architecture Design Document

Architecture Design Document

This document outlines the MVP architecture for a mobile-first, high-concurrency social media platform. It explicitly focuses on the core media ingestion and hybrid feed fan-out pipelines capable of scaling to millions of users. Real-time features (like Direct Messaging) are deliberately scoped out of this phase to maintain a clean, declarative boundary suitable for an AI coding agent practicing Compound Engineering.

TL;DR

Chroma is a mobile-first photo and short video sharing platform — an Instagram-style app targeting rapid growth to several million users. The backend is a set of containerized Go services backed by PostgreSQL and Redis, with media processed by self-hosted workers and delivered through a CDN. The two most important architectural choices are: a hybrid feed system that pre-computes ranked feeds for active users while building feeds on-demand for inactive ones (balancing freshness with infrastructure cost), and a counter-sharding pattern for likes/comments that lets the system absorb millions of concurrent interactions without write contention.

Executive Summary

Chroma is built as a small cluster of purpose-scoped backend services written in Go, deployed as containers on a managed Kubernetes service (GKE Autopilot). PostgreSQL (Cloud SQL) is the primary relational store for user profiles, social graph, and media metadata. Redis (Memorystore) provides the hot caching layer for feed queues, like counts, session tokens, and algorithmic ranking signals. Media files live in Google Cloud Storage behind a Cloud CDN.

The feed pipeline is the most architecturally consequential subsystem. It uses a hybrid fanout model: when an active user posts, a background Fanout Worker pushes post references into per-user Redis sorted sets (fanout-on-write); for users with no recent session, the feed is assembled fresh from the social graph at read time (fanout-on-read). The algorithmic ranking layer — scoring posts by recency, engagement velocity, and relationship proximity — runs as a lightweight scoring pass over the candidate set already assembled in Redis. This avoids a separate ML serving infrastructure at this stage.

Likes and comments are write-heavy at scale. Rather than a single counter column, Chroma uses Redis counter sharding and a periodic flush to PostgreSQL via a dedicated Counter Sync Worker. This absorbs burst write traffic without hot-row contention.

GDPR compliance is handled via a dedicated data deletion pipeline and geographic data-residency routing. CSAM detection is integrated as a pre-publication scan against the PhotoDNA or equivalent hashing service, with media held in a quarantine bucket until cleared. Content moderation is event-driven, triggered on upload completion.

The architecture deliberately keeps operational complexity within reach of a 1–3 person team by leaning on managed services (Cloud SQL, Memorystore, GKE Autopilot) and avoiding self-managed Kafka or Elasticsearch at launch — background workers use PostgreSQL-backed job queues (pgqueue pattern via River), and search is handled by PostgreSQL full-text search with a migration path to Typesense or Elasticsearch when needed.

Major tradeoffs:

  • Go over Node.js/Python: better concurrency primitives for the high-throughput like/comment path; reasonable learning curve.
  • River (pgqueue) over Kafka: eliminates an entire managed cluster; sufficient for the first phase of growth, replaceable later.
  • PostgreSQL FTS over Elasticsearch: reduces operational surface; acceptable search quality at launch scale.
  • GKE Autopilot over self-managed nodes: removes node-level ops burden for a small team while staying within budget.

System Overview

graph TD
    subgraph clients["Client Layer"]
        ios["iOS App
(React Native)"]
        android["Android App
(React Native)"]
    end

    subgraph gateway["API Gateway"]
        kong["Kong 3.6
(JWT Auth + Rate Limit)"]
    end

    subgraph core["Core Services"]
        content["Content Service
(Go 1.23)"]
        social["Social Service
(Go 1.23)"]
        feed["Feed Service
(Go 1.23)"]
    end

    subgraph workers["Async Workers"]
        fanout["Fanout Worker
(Go 1.23)"]
        media["Media Processing
Worker (Go 1.23)"]
        counter["Counter Sync
Worker (Go 1.23)"]
        notif["Notification
Service (Go 1.23)"]
    end

    subgraph storage["Data Storage"]
        pg["Cloud SQL
PostgreSQL 16"]
        redis["Memorystore
Redis 7"]
    end

    subgraph mediaInfra["Media Infrastructure"]
        gcsQ["GCS Quarantine
Bucket"]
        gcsD["GCS Delivery
Bucket"]
        cdn["Cloud CDN"]
    end

    subgraph external["External Services"]
        fcm["Firebase Cloud
Messaging"]
        fbauth["Firebase Auth
(OTP)"]
        photodna["PhotoDNA
(CSAM Scan)"]
    end

    subgraph queue["Job Queue"]
        river["River
(pgqueue on Cloud SQL)"]
    end

    ios --> kong
    android --> kong

    kong --> content
    kong --> social
    kong --> feed

    content --> pg
    content --> redis
    content --> river
    content --> gcsQ

    social --> pg
    social --> redis

    feed --> redis
    feed --> pg
    feed --> content

    river --> fanout
    river --> media
    river --> notif

    fanout --> pg
    fanout --> redis

    media --> gcsQ
    media --> gcsD
    media --> photodna
    media --> content

    counter --> redis
    counter --> pg

    notif --> fcm
    notif --> pg

    gcsD --> cdn

    ios -.->|"direct upload
(signed URL)"| gcsQ
    android -.->|"direct upload
(signed URL)"| gcsQ

    ios <-.->|"media delivery"| cdn
    android <-.->|"media delivery"| cdn

    kong <-.->|"OTP verify"| fbauth

Users interact with Chroma exclusively via mobile apps (iOS and Android) built with React Native. The apps talk to a single API Gateway (Kong, running in the cluster) which handles authentication, rate limiting, and request routing to backend services.

Three primary backend services handle the core domain:

  • Content Service: owns post creation, media metadata, hashtag indexing, and post retrieval.
  • Social Service: owns follow/follower relationships, the user profile graph, and search.
  • Feed Service: owns feed assembly, algorithmic scoring, and Reels discovery feed.

Two supporting async services run as background workers:

  • Fanout Worker: consumes post-creation events from the internal job queue and fans post references out to follower feed queues in Redis.
  • Counter Sync Worker: periodically flushes Redis like/comment counters to PostgreSQL to prevent counter drift.
  • Media Processing Worker: picks up raw media uploads, runs FFmpeg for video transcoding and thumbnail extraction, runs image resizing, triggers CSAM scan, and moves processed files to the delivery bucket in GCS.

The Notification Service handles both push notifications (via Firebase Cloud Messaging) and in-app notifications, consuming events from the job queue.

Cloud SQL (PostgreSQL 16) holds all relational data. Redis 7 (Memorystore) holds feed queues, counter shards, session tokens, and ranking signal caches. GCS holds raw media (quarantine bucket) and processed media (delivery bucket). Cloud CDN sits in front of the delivery bucket.

Data flows for the two most critical paths:

Post creation: Mobile app uploads media directly to a GCS signed URL → app notifies Content Service of upload completion → Content Service writes metadata to PostgreSQL and enqueues a media processing job → Media Worker processes and moves file to delivery bucket → Content Service marks post as published → Fanout Worker fans post reference into follower feed queues in Redis → Notification Service sends push notifications to mentioned users.

Feed read: Feed Service checks Redis for the requesting user's pre-computed feed queue → if populated (active user), returns ranked set; if empty or stale (inactive user), assembles candidate set from PostgreSQL social graph, scores and writes back to Redis, returns result. All media URLs are CDN-prefixed at response time.

Architectural Constraints Summary

The budget ceiling ($2,000–$10,000/month) and the small team size (1–3 people) are the dominant constraints that shaped almost every decision.

Budget drove managed-service selection: Cloud SQL, Memorystore, and GKE Autopilot are more expensive per resource than self-managed equivalents, but they eliminate entire categories of operational toil (backups, failover, patching, node management). For a solo developer, that tradeoff is unambiguous.

Team size drove queue technology: Kafka is the canonical choice for a fanout system at this scale, but operating a Kafka cluster reliably requires dedicated expertise. River (a Go-native, PostgreSQL-backed job queue) provides durable, ordered, at-least-once job delivery without a separate cluster. The migration path to Pub/Sub or Kafka is explicitly planned for but not required at launch.

Scale target drove the hybrid feed model: Fanout-on-write for all users would require Redis memory proportional to the follower count of every active poster. At several million users, celebrity accounts (high follower count) would make pure fanout-on-write prohibitively expensive. The hybrid model caps Redis memory use while still delivering near-real-time feeds to active users.

Scale target also drove counter sharding: A single PostgreSQL likes_count column under high-concurrency writes produces hot-row lock contention. Redis counter shards absorb the burst; the async flush keeps PostgreSQL eventually consistent.

GDPR and CSAM are non-negotiable foundational constraints that added a deletion pipeline, data residency controls, and a pre-publication media scanning step. These are not optional features — they are part of the publish path.

Mobile-first with no existing codebase meant React Native was chosen over native iOS/Android — it's the most efficient path for a small team to ship both platforms from a single codebase.

Component Architecture

API Gateway (Kong 3.6)

Kong runs as a Kubernetes deployment (2 replicas minimum). It handles:

  • JWT validation for every authenticated request (tokens issued by the Auth subsystem, validated via shared secret).
  • Per-user and per-IP rate limiting using the Redis-backed rate-limit plugin (limits: 300 requests/min per user for standard endpoints, 30/min for write endpoints).
  • Request routing to the correct backend service by path prefix.
  • Request/response logging to Cloud Logging.

Kong does not contain business logic. It fails open on rate-limit-store unavailability (Redis down) with a conservative in-memory fallback, logging the degraded state.

Auth Subsystem (embedded in API Gateway + Social Service)

Authentication is JWT-based (RS256, 15-minute access tokens, 30-day refresh tokens). The Social Service issues tokens on login/registration. Refresh tokens are stored in PostgreSQL with a revocation flag. Kong validates access tokens on every request using the public key. There is no separate auth service — this keeps the operational footprint small.

Password hashing uses bcrypt (cost factor 12). Phone number verification (for registration) uses Firebase Auth OTP flow — this offloads SMS delivery complexity.

Content Service (Go 1.23)

Responsibilities: post creation lifecycle, media metadata storage, hashtag parsing and indexing, post retrieval (single post, user profile posts), and delete/archive operations.

Post creation is a two-phase operation. Phase 1: the client requests a GCS signed upload URL and a pending post record is written to PostgreSQL. Phase 2: after the client confirms upload completion, the Content Service validates the upload exists in the quarantine bucket, enqueues a media processing job, and transitions the post state to processing. The post only becomes publicly visible after the Media Worker marks it published. This prevents partially-processed media from being served.

Hashtags are extracted from captions server-side during Phase 2. The Content Service writes hashtag-to-post associations to PostgreSQL and increments a hashtag use-count in Redis (used for trending calculation).

Social Service (Go 1.23)

Responsibilities: user registration, login, profile management, follow/unfollow operations, follower/following lists, user search, and block relationships.

The follow graph is stored in PostgreSQL. For hot queries (e.g., "does user A follow user B?" — needed on nearly every feed item render), results are cached in Redis with a 5-minute TTL. The Social Service also maintains a Redis sorted set of "recently active users" (keyed by last API call timestamp) that the Fanout Worker consults to decide whether to fan out to a given follower.

User search uses PostgreSQL full-text search on username and display name columns. A GIN index on the tsvector column provides acceptable performance at launch scale (up to ~2M users). When search latency degrades, the migration path is to Typesense 0.25 running as a sidecar-populated index.

Feed Service (Go 1.23)

Responsibilities: assembling and returning the home feed, the Reels discovery feed, and the explore/hashtag feed.

Home feed (hybrid fanout):

For active users, the feed is a Redis sorted set keyed per user, scored by the algorithmic rank of each post. The Feed Service reads from this sorted set, paginates via cursor (the score of the last-seen post), and fetches post metadata from the Content Service (or Redis post cache) to hydrate the response.

For inactive users (not seen in the last 48 hours, determined by absence from the active-user Redis set), the Feed Service executes a fanout-on-read: it queries PostgreSQL for the list of accounts the user follows, fetches the most recent posts from each (last 7 days), scores them, writes the result back to the user's Redis feed queue (for subsequent requests within the session), and returns the first page.

Algorithmic scoring: The scoring function is a weighted sum computed entirely in-process, with no external ML model at launch. The weights are:

  • Recency: score decays exponentially with post age, half-life of 6 hours.
  • Engagement velocity: likes and comments in the last hour, fetched from Redis counters, normalized by the poster's average engagement rate (stored in Redis, updated by the Counter Sync Worker).
  • Relationship proximity: posts from users the requester has interacted with (liked, commented, DM'd) in the last 30 days get a 1.5x multiplier. This interaction history is a small Redis set per user, capped at 200 entries.
  • Content type preference: if the user's last 20 feed interactions were predominantly video, video posts get a 1.2x multiplier. Preference signal stored in Redis.

Scores are recomputed fresh on each fanout-on-read pass. For pre-computed feeds, scores are recomputed by the Fanout Worker on each new post insertion and the sorted set is re-sorted in Redis.

Reels discovery feed: A separate feed, assembled on-read for all users (no per-user pre-computation). Candidates are pulled from a Redis sorted set of "trending Reels" (maintained by the Counter Sync Worker based on engagement velocity of video posts in the last 24 hours). Personalization is applied as a lightweight re-scoring pass using the relationship proximity and content preference signals described above.

Fanout Worker (Go 1.23, River job consumer)

Triggered by a post_published job enqueued by the Content Service. For each published post:

  1. Fetches the poster's follower list from PostgreSQL (paginated in batches of 500 to avoid memory spikes on high-follower accounts).
  2. For each follower, checks the active-user Redis set. If active, pushes the post reference (post ID + computed score) into the follower's Redis feed sorted set and trims the set to the last 1,000 entries.
  3. If the poster has more than 50,000 followers (celebrity threshold), fanout is capped at the 50,000 most recently active followers. The remaining followers receive fanout-on-read.
  4. Enqueues a notify_followers job for the Notification Service.

This worker runs with 5 concurrent goroutines per pod, 2 pods minimum.

Media Processing Worker (Go 1.23, River job consumer)

Triggered by a process_media job. Steps in order:

  1. Downloads raw file from the GCS quarantine bucket.
  2. Runs CSAM hash scan (PhotoDNA API call). If flagged, moves file to a separate restricted bucket, marks the post removed_csam, notifies the moderation queue, and terminates processing. The post never becomes visible.
  3. For images: generates three resized variants (thumbnail 150×150, feed 1080w, full 1440w) using the imaging Go library.
  4. For videos (up to 60 seconds): runs FFmpeg 6.1 to transcode to H.264/AAC in MP4 container at 720p and 1080p, extracts a thumbnail frame at 1 second, generates a 3-second preview GIF/WebP for feed previews.
  5. Uploads all processed variants to the GCS delivery bucket.
  6. Calls back to the Content Service to mark the post published with the processed media URLs.
  7. Deletes the raw file from the quarantine bucket.

This worker runs with 2 concurrent goroutines per pod (CPU-bound FFmpeg work), autoscaled by job queue depth.

Counter Sync Worker (Go 1.23, scheduled)

Runs every 30 seconds. Reads all dirty counter shards from Redis (like counts, comment counts per post), flushes the delta to PostgreSQL using upsert, and clears the dirty flag. Also recalculates engagement velocity metrics for the top 10,000 most-engaged posts (by raw Redis counter activity in the last hour) and writes them back to Redis for the Feed Service scoring pass. Maintains the trending Reels sorted set in Redis.

Notification Service (Go 1.23, River job consumer)

Consumes notify_followers, notify_mention, notify_comment, and notify_like jobs. Sends push notifications via FCM (Firebase Cloud Messaging) using the FCM HTTP v1 API. Writes in-app notification records to PostgreSQL. Deduplicates like notifications (if a user has already been notified about likes on a post in the last 10 minutes, additional like notifications are batched). Device tokens are stored in PostgreSQL, associated with user accounts.

Data Architecture

Storage Layers

Layer Technology What Lives Here
Primary relational store Cloud SQL PostgreSQL 16 Users, posts, follows, comments, hashtags, in-app notifications, job queue (River tables), device tokens, GDPR deletion requests
Hot cache & feed queues Redis 7 (Memorystore) Per-user feed sorted sets, like/comment counter shards, session tokens, active-user set, hashtag trending counters, engagement velocity signals, relationship proximity sets
Media (raw/quarantine) GCS bucket (private, same region) Uploaded raw media before processing
Media (processed/delivery) GCS bucket (CDN-backed, public-read) Processed image variants and transcoded video files
Secrets Google Secret Manager Database credentials, FCM service account, PhotoDNA API key, JWT signing keys

Core Data Entities

Users

Entity Attribute Type + Constraints Business Rule & Access Control
User id UUID, PK Immutable after creation
User username VARCHAR(30), UNIQUE, NOT NULL Alphanumeric + underscores only; case-insensitive uniqueness via lowercased index
User phone_hash VARCHAR(64), UNIQUE SHA-256 of E.164 phone number; used for login lookup; raw phone never stored
User profile_pic_url TEXT, NULLABLE Must point to delivery bucket CDN domain; validated server-side
User bio VARCHAR(150), NULLABLE Stripped of HTML on write
User is_private BOOLEAN, DEFAULT FALSE If true, follow requests require approval; content not visible to non-followers
User is_deleted BOOLEAN, DEFAULT FALSE Soft delete; GDPR hard delete pipeline removes PII 30 days after flag set
User gdpr_deletion_requested_at TIMESTAMPTZ, NULLABLE Set when user requests account deletion; triggers deletion pipeline
User created_at TIMESTAMPTZ, NOT NULL, DEFAULT NOW() Immutable

Posts

Entity Attribute Type + Constraints Business Rule & Access Control
Post id UUID, PK Immutable
Post author_id UUID, FK → Users, NOT NULL Indexed; used for profile feed queries
Post media_type ENUM('photo','video'), NOT NULL Determines processing path
Post state ENUM('pending','processing','published','removed_csam','removed_moderation','deleted'), NOT NULL State machine; only published posts are returned in feeds
Post caption TEXT, NULLABLE, MAX 2200 chars Stripped of HTML on write; hashtags extracted server-side
Post location VARCHAR(100), NULLABLE Free-text location label
Post like_count BIGINT, DEFAULT 0 Authoritative count written by Counter Sync Worker; Redis is source of truth for real-time display
Post comment_count BIGINT, DEFAULT 0 Same sync pattern as like_count
Post is_reel BOOLEAN, DEFAULT FALSE True if video ≤ 60s intended for Reels feed
Post created_at TIMESTAMPTZ, NOT NULL, DEFAULT NOW() Used as base for recency scoring; indexed
Post published_at TIMESTAMPTZ, NULLABLE Set when state transitions to published; used for feed freshness

Follows

Entity Attribute Type + Constraints Business Rule & Access Control
Follow follower_id UUID, FK → Users, NOT NULL Composite PK with followee_id
Follow followee_id UUID, FK → Users, NOT NULL Composite PK with follower_id
Follow state ENUM('pending','accepted'), NOT NULL pending when followee has private account; becomes accepted on approval
Follow created_at TIMESTAMPTZ, NOT NULL, DEFAULT NOW() Indexed for "new followers" feed

Comments

Entity Attribute Type + Constraints Business Rule & Access Control
Comment id UUID, PK Immutable
Comment post_id UUID, FK → Posts, NOT NULL, Indexed Loaded in batches for post view
Comment author_id UUID, FK → Users, NOT NULL Author can delete own comment; post owner can delete any comment on their post
Comment body TEXT, NOT NULL, MAX 2200 chars Stripped of HTML; mention parsing server-side
Comment parent_id UUID, FK → Comments, NULLABLE For threaded replies (one level deep only)
Comment created_at TIMESTAMPTZ, NOT NULL, DEFAULT NOW() Ordered by this for display

Likes

Likes are stored as rows in a likes table (post_id + user_id composite PK, UNIQUE constraint prevents duplicate likes). Like counts are not read from this table in real time — they come from Redis. The table is used for: determining whether the current user has liked a specific post (point lookup by composite key), and for the GDPR deletion pipeline to remove user activity. The table is partitioned by post_id hash (8 partitions) to keep index sizes manageable at scale.

Hashtags

A hashtags table (tag text, UNIQUE) with a junction table post_hashtags (post_id, hashtag_id). Hashtag use counts live in Redis for trending; PostgreSQL stores the canonical associations for search and GDPR-compliant deletion.

Data Lifecycle

  • Feed queues: Redis sorted sets, capped at 1,000 entries per user, TTL of 7 days on inactivity.
  • Counter shards: Redis keys with no TTL; flushed to PostgreSQL every 30 seconds.
  • Session tokens: Redis keys, TTL matches access token expiry (15 minutes).
  • GDPR deletion: When gdpr_deletion_requested_at is set, a scheduled job (runs daily) hard-deletes PII (username, phone_hash, bio, profile_pic) and replaces with anonymized placeholders after 30 days. Posts are either deleted or attributed to an anonymous account, per the user's choice at deletion request time. All associated likes, comments, and follow relationships are deleted. Media files in GCS are deleted via lifecycle rule triggered by the job.
  • Media files: Quarantine bucket has a 24-hour object lifecycle rule that auto-deletes objects not moved to the delivery bucket (catches failed processing jobs). Delivery bucket has no automatic expiry; deletion is triggered explicitly by the GDPR pipeline or content moderation.

Integration Architecture

Google Cloud Storage

Purpose: Raw media ingestion (quarantine bucket) and processed media delivery (delivery bucket).

Integration method: The Content Service generates GCS signed upload URLs (V4 signing, 15-minute expiry) that the mobile client uses to upload directly to the quarantine bucket. This offloads upload bandwidth from the backend. After upload, the client notifies the Content Service, which validates the object exists and enqueues a processing job. The Media Worker uses the GCS Go client library with a service account (least-privilege IAM role) to move objects between buckets.

Auth: Service account with roles/storage.objectAdmin scoped to the two media buckets. Signed URLs are generated using the service account's signing key stored in Secret Manager.

Failure behavior: If the client fails to upload within the signed URL window, the Phase 2 confirmation call fails validation (object not found in quarantine bucket), the pending post record is marked failed_upload, and the client receives an error prompting retry. If the Media Worker fails to reach GCS, the River job is retried with exponential backoff (up to 5 attempts over ~2 hours). After 5 failures, the job moves to the dead-letter queue and the post remains in processing state — an alert fires for manual investigation.

Cloud CDN + Cloud Load Balancing

Purpose: Low-latency media delivery globally.

Integration: Cloud CDN is configured as a backend bucket in front of the GCS delivery bucket. All media URLs returned by the API are CDN-prefixed (e.g., cdn.chroma.app/...). Cache TTL for media objects is set to 30 days (immutable after upload). No signed URL required for delivery — files in the delivery bucket are public-read by CDN, not directly by users (bucket-level public access is off; CDN serves via its own identity).

Failure behavior: CDN miss falls back to GCS origin. CDN outage would degrade media load times but not API availability; the app shows placeholder images and retries.

Firebase Cloud Messaging (FCM)

Purpose: Push notifications to iOS and Android.

Integration: Notification Service calls the FCM HTTP v1 API using a service account credential. Device tokens are registered by the mobile client on login and updated on token refresh. The Notification Service sends to individual tokens and handles token invalidation responses by deleting the stale token from PostgreSQL.

Failure behavior: FCM call failure is retried by the River job (up to 3 attempts). After 3 failures, the notification is dropped and logged — push notification delivery is best-effort and does not block other operations.

Firebase Auth (OTP/Phone Verification)

Purpose: Phone number verification at registration without self-managing SMS delivery.

Integration: The mobile client uses the Firebase Auth SDK to complete the OTP flow client-side. On success, Firebase issues a short-lived ID token. The client sends this token to the Social Service, which verifies it against Firebase's public keys, extracts the phone number, and proceeds with account creation. The Social Service never sees the raw SMS OTP.

Failure behavior: If Firebase Auth verification fails (invalid or expired token), registration is rejected with a clear user-facing error. No retry — the client restarts the OTP flow.

PhotoDNA (CSAM Detection)

Purpose: Pre-publication CSAM scanning of all uploaded media, as required by applicable law.

Integration: The Media Processing Worker calls the PhotoDNA API (Microsoft) after downloading the raw file from the quarantine bucket, before any processing. The API returns a match/no-match result against the NCMEC CSAM hash database.

Auth: API key stored in Secret Manager, injected as an environment variable into the worker pod.

Failure behavior: If the PhotoDNA API is unreachable or returns an error, the media job is retried (up to 3 attempts with backoff). If all retries fail, the post remains in processing state and an alert fires — the post is never published without a successful scan result. This is a hard safety gate.

Pre-launch verification: PhotoDNA provides a sandbox/test API with known-safe and known-flagged test hashes. Integration tests in CI run against the sandbox endpoint.

River (PostgreSQL-backed Job Queue)

Purpose: Durable async job delivery for fanout, media processing, notifications, and counter sync.

Integration: All services enqueue and consume jobs using the River Go client against the same Cloud SQL instance (a dedicated jobs schema). River uses PostgreSQL advisory locks and SKIP LOCKED for job pickup — no separate broker.

Failure behavior: Jobs are retried with exponential backoff on failure. Dead-letter queue holds jobs that exceed the retry limit. The River dashboard (a lightweight web UI deployable as a container) provides visibility into queue depth and dead-letter items.

Security Architecture

Authentication and Authorization

JWT access tokens (RS256) are validated at the API Gateway layer. The private key lives in Secret Manager; Kong fetches it at startup and caches it. All write operations additionally verify that the authenticated user is the resource owner (e.g., only the post author can delete their post). Block relationships are enforced at the Social Service layer: blocked users cannot see each other's content, appear in each other's search results, or follow each other.

Input Validation

All user-supplied text (captions, bios, comments, usernames) is validated and sanitized at the service layer before write: length limits are enforced, HTML is stripped, and null bytes are rejected. Media type is validated by inspecting the file's magic bytes in the Media Worker, not by trusting the client-provided MIME type. GCS signed URLs are scoped to a specific object key and bucket — clients cannot upload to arbitrary paths.

Secrets Management

All secrets (DB credentials, API keys, JWT signing keys, FCM service account JSON) live in Google Secret Manager. Kubernetes pods access secrets via Workload Identity (no static credentials in environment variables or config files). Secret rotation is handled in Secret Manager; services reload secrets on pod restart.

GDPR Compliance

  • Users can request account deletion through the app; this sets gdpr_deletion_requested_at.
  • A scheduled daily job processes deletions older than 30 days: PII columns are overwritten, media files are deleted from GCS, and all associated social graph data is removed.
  • Data export (right to portability) is implemented as an async job that packages the user's posts, comments, and profile data into a JSON archive, uploads it to a temporary private GCS URL, and emails the download link (30-minute expiry).
  • All API responses for user data associated with a deleted account return anonymized placeholders.
  • Regional data residency: the Cloud SQL instance and GCS buckets are deployed in europe-west1 (Belgium) and us-central1 with replication. European user accounts are flagged at registration (based on IP geolocation) and their data is written to the EU-region instance. The API Gateway routes EU user requests to an EU-region backend deployment.

CSAM and Content Moderation

CSAM scanning is a hard gate in the media processing pipeline (see Integration Architecture). Additionally, a human review queue is maintained in a simple admin web UI (internal only, behind VPN). Flagged content (from user reports or automated signals) is moved to removed_moderation state immediately on flag, pending review. User reporting is an async operation that does not block the reporter.

Network Security

  • All external traffic is HTTPS only; HTTP is redirected to HTTPS at the load balancer.
  • CORS policy: the API only accepts requests from the registered mobile app origins. Web access is not a target for this phase.
  • Security headers set at the API Gateway: Strict-Transport-Security (2-year max-age, includeSubDomains), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer.
  • Internal service-to-service traffic runs within the GKE cluster's private VPC; services are not publicly exposed.
  • The Cloud SQL instance is private (no public IP); accessible only via the private VPC from within the cluster.
  • Redis (Memorystore) is private-only, accessible within the VPC.

Rate Limiting

Kong enforces rate limits per user (authenticated) and per IP (unauthenticated). Write endpoints (post creation, like, comment, follow) have stricter limits than read endpoints to limit abuse. Rate limit state is stored in Redis; degraded fallback uses in-memory counters when Redis is unavailable.

Deployment Architecture

Infrastructure

All infrastructure runs on Google Cloud Platform in two regions: us-central1 (primary) and europe-west1 (EU residency requirement). Both regions run identical cluster configurations.

  • GKE Autopilot: Managed Kubernetes; Google manages node provisioning, patching, and scaling. Autopilot pricing is per-pod-resource, not per-node, which fits the budget model of a small team.
  • Cloud SQL PostgreSQL 16: HA configuration (primary + standby in the same region, automatic failover). Point-in-time recovery enabled, 7-day backup retention. Private IP only.
  • Redis 7 (Memorystore): Standard tier (replication enabled), 10 GB starting capacity. Auto-failover to replica on primary failure.
  • Google Cloud Storage: Two buckets per region (quarantine, delivery). Delivery bucket backed by Cloud CDN.
  • Google Secret Manager: Single global instance; accessed via Workload Identity from both regional clusters.

Kubernetes Deployment

Services are deployed as Kubernetes Deployments with the following replica minimums:

Service Min Replicas Max Replicas Scaling Trigger
API Gateway (Kong) 2 10 CPU > 60%
Content Service 2 8 CPU > 70%
Social Service 2 6 CPU > 70%
Feed Service 2 8 Memory > 70% (feed assembly is memory-heavy)
Fanout Worker 2 6 River queue depth > 500 jobs
Media Processing Worker 1 4 River queue depth > 10 jobs
Counter Sync Worker 1 1 Scheduled; no autoscaling
Notification Service 2 6 River queue depth > 200 jobs

All Deployments use PodDisruptionBudgets (min available: 1) to ensure rolling updates don't take services fully offline.

CI/CD Pipeline

GitHub Actions is the CI/CD platform. On push to main:

  1. Run Go unit tests and integration tests (against a PostgreSQL + Redis test container via testcontainers-go).
  2. Build Docker images (multi-stage builds, scratch-based final image for Go binaries).
  3. Push images to Google Artifact Registry.
  4. Run helm upgrade --install against the target cluster (Helm 3 charts, one chart per service).
  5. Monitor rollout health via kubectl rollout status; auto-rollback on failure.

Separate environments: staging (single-region, reduced replicas) and production (dual-region). Staging deploys automatically on merge to main; production requires a manual approval step in GitHub Actions.

Budget Estimate

At 500,000 active users, the estimated monthly GCP spend:

  • GKE Autopilot (all pods): ~$800–$1,200
  • Cloud SQL (HA, db-standard-4): ~$400–$600
  • Memorystore Redis (10 GB standard): ~$200–$300
  • GCS storage + egress: ~$200–$500 (depending on media volume)
  • Cloud CDN: ~$100–$300
  • Other (Secret Manager, Artifact Registry, logging): ~$100

Total: ~$1,800–$3,000/month at 500K active users. Headroom exists for growth within the $10,000 ceiling before requiring architectural cost optimization.

Environments

  • Local development: Docker Compose with PostgreSQL, Redis, and MinIO (GCS-compatible) for local media simulation.
  • Staging: GKE Autopilot (us-central1 only), shared Cloud SQL, reduced replicas. Auto-deployed from main.
  • Production: GKE Autopilot (us-central1 + europe-west1), HA Cloud SQL, full replica counts.

Technology Stack

Technology Version Role Why Chosen
Go 1.23 All backend services and workers Best-in-class concurrency for high-throughput like/comment paths; low memory footprint per goroutine; strong standard library reduces dependencies
React Native 0.74 iOS + Android mobile apps Single codebase for both platforms; mature ecosystem; most efficient path for a small team to ship mobile
PostgreSQL 16 Primary relational store ACID guarantees for social graph and metadata; excellent full-text search with GIN indexes; River runs on top of it natively
Redis 7 Feed queues, counter shards, caching Sorted sets are the native data structure for scored feed queues; sub-millisecond counter increments for like paths
River 0.9 PostgreSQL-backed job queue Eliminates Kafka/Pub/Sub operational overhead; durable, at-least-once delivery; native Go client; runs on existing Cloud SQL
Kong 3.6 API Gateway Mature, Kubernetes-native; declarative config via KongIngress; robust rate-limiting and JWT plugins
GKE Autopilot Current Container orchestration Removes node-management burden from small team; auto-scales pod resources; GCP-native integrations
Cloud SQL PostgreSQL 16 Managed relational DB HA failover, PITR, private networking without self-managing Postgres
Memorystore Redis 7 Managed Redis Auto-failover, no cluster management, private VPC
Google Cloud Storage N/A Media object storage Native GKE/GCP integration; signed URL upload pattern; lifecycle rules for quarantine bucket
Cloud CDN N/A Media delivery Integrated with GCS; global PoPs; immutable media caching
Firebase Cloud Messaging v1 API Push notifications Cross-platform (iOS + Android) push without managing APNs/FCM separately; free tier covers launch scale
Firebase Auth Current SDK Phone OTP verification Offloads SMS delivery and OTP management; client-side flow reduces backend surface area
FFmpeg 6.1 Video transcoding Industry standard; H.264 output maximizes device compatibility
PhotoDNA Current API CSAM detection NCMEC-partnered; required for legal compliance; hash-based matching is fast and does not require storing illegal content
Helm 3 Kubernetes deployment packaging Standard Kubernetes packaging; parameterized charts for staging/production
GitHub Actions Current CI/CD Integrated with GitHub; sufficient for small team; no additional tooling to operate
Google Artifact Registry Current Container image registry GCP-native; integrates with GKE Workload Identity for pull auth
Google Secret Manager Current Secrets management Workload Identity integration; no static credentials in pods
Typesense 0.25 Search (migration target, not at launch) Planned upgrade path from PostgreSQL FTS when search latency degrades at scale

Key Architectural Decisions

ADR-001: Go for all backend services Decided: Go 1.23 for all services and workers. Alternatives: Node.js (TypeScript), Python (FastAPI). Go won because the high-concurrency like/comment path and the feed assembly loops are compute-bound in ways that benefit from Go's goroutine scheduler and low per-connection memory cost. At several million users, the memory efficiency advantage compounds. Node.js would have been viable but requires more care around CPU-intensive work (FFmpeg, image processing). Python was ruled out due to the GIL and slower cold starts under Autopilot's scale-to-zero behavior.

ADR-002: River (pgqueue) over Kafka or Google Pub/Sub Decided: River 0.9 running on Cloud SQL PostgreSQL. Alternatives: Apache Kafka (managed via Confluent or self-hosted), Google Pub/Sub. River won because it eliminates an entire managed cluster. For a 1–3 person team, Kafka operational complexity (consumer groups, offset management, partition tuning) is a significant burden. Pub/Sub would have been a reasonable alternative but requires more infrastructure glue. River's throughput (tens of thousands of jobs/second on PostgreSQL) is sufficient for the first 12 months. The migration path to Pub/Sub is clear when it becomes necessary.

ADR-003: Hybrid fanout model (write for active, read for inactive) Decided: Fanout-on-write for users active in the last 48 hours; fanout-on-read for inactive users. Alternatives: Pure fanout-on-write for all users, pure fanout-on-read (Twitter's original model). Pure fanout-on-write at several million users with celebrity accounts (50K+ followers) would require Redis sorted sets for every follower — memory cost scales with follower count × posts per day × active users. Pure fanout-on-read degrades under high follow counts. The hybrid model bounds Redis memory use while delivering the near-real-time feed freshness the product requires.

ADR-004: Counter sharding in Redis for likes/comments Decided: Likes and comments are incremented in Redis shards and flushed to PostgreSQL every 30 seconds. Alternatives: Write every like directly to PostgreSQL (hot-row locking under concurrency), use optimistic locking (still contention at scale). Redis atomic increments on counter keys eliminate PostgreSQL write contention entirely. The 30-second flush window means displayed counts may lag by up to 30 seconds — acceptable for a like count but not for comments (comments are written to PostgreSQL synchronously and read from there; only the count is async).

ADR-005: PostgreSQL FTS over Elasticsearch at launch Decided: PostgreSQL full-text search with GIN index for user and hashtag search. Alternatives: Elasticsearch 8, Typesense 0.25. Elasticsearch requires a separate cluster, index management, and a separate sync pipeline — significant operational overhead for a small team. PostgreSQL FTS is sufficient for user search at up to ~2 million rows. Typesense is the planned upgrade path: it's lightweight, easy to operate, and has a clean API for fuzzy search. The migration is a single new service + a one-time index sync job.

ADR-006: GCS direct upload via signed URL Decided: Clients upload media directly to GCS using signed URLs; backend is not in the upload path. Alternatives: Upload through the API Gateway (multipart form), use a separate upload service. Direct-to-GCS upload removes upload bandwidth and processing load from backend services entirely. The API Gateway would become the bottleneck for a photo/video-heavy app if all media transited through it. The signed URL pattern is standard practice for GCS/S3 media upload.

ADR-007: React Native for mobile Decided: React Native 0.74 for iOS and Android. Alternatives: Native Swift/Kotlin (two separate codebases), Flutter (Dart). For a team of 1–3, maintaining two native codebases is impractical. Flutter is a strong alternative but React Native has a larger ecosystem of video/camera libraries critical for a media app (react-native-vision-camera, react-native-video). React Native 0.74 with the new architecture (JSI, Fabric) provides performance sufficient for video playback and camera capture.

ADR-008: Deferring real-time messaging infrastructure Decided: Exclude WebSocket gateways and real-time message routing from the initial architecture. Alternatives: Build a parallel real-time subsystem alongside the MVP. Messaging requires a fundamentally different architectural profile (persistent WebSocket connections, Redis Pub/Sub backplanes for cross-pod routing, and append-only time-series storage). To keep the operational boundary tight and the API Gateway focused entirely on stateless request/response traffic, this subsystem is explicitly deferred. When introduced, Kong will be updated to route /ws traffic to a newly provisioned, isolated Messaging Service cluster.

Error Handling & Failure Modes

API Gateway (Kong)

Kong rate-limit plugin stores state in Redis. If Redis is unavailable, Kong falls back to in-memory rate limiting per pod (rate limits are applied per-pod rather than globally, so effective rate is multiplied by pod count). This is logged as a degraded-state alert. JWT validation failure (expired, malformed, or tampered token) returns an authentication error to the client immediately with no retry.

Content Service: Two-Phase Post Creation

What happens if the client uploads successfully but fails to call the Content Service for Phase 2 confirmation? The pending post record in PostgreSQL has a created_at timestamp. A scheduled cleanup job (runs every hour) marks any pending post older than 1 hour as failed_upload and deletes the associated quarantine object if it exists. The client can retry the Phase 2 call within the 15-minute signed URL window; after expiry, it must request a new signed URL and re-upload.

What happens if the Media Worker fails partway through processing (e.g., FFmpeg crashes on a malformed video)? The River job records the failure, increments the attempt counter, and retries with backoff (attempts at 1 min, 5 min, 20 min, 1 hour, 2 hours). After 5 failures, the job moves to the dead-letter queue, an alert fires, and the post remains in processing state indefinitely until manually resolved. The quarantine object is not deleted in this case (for debugging).

Feed Service

If Redis is unreachable when the Feed Service attempts to read a user's feed queue, the Feed Service falls back to a full fanout-on-read from PostgreSQL for that request. This is slower but functional. The fallback is logged and alerted so Redis outages don't silently degrade to database overload. If the PostgreSQL fallback also fails (DB unavailable), the API returns a service-unavailable error with a retry-after hint.

Fanout Worker

What happens if the Fanout Worker crashes partway through fanning out a post with 100,000 followers? The River job is marked in-progress until the worker heartbeat expires (30-second timeout). River then makes the job available for pickup by another worker instance. The fanout operation is designed to be idempotent: pushing a post reference into a sorted set in Redis with the same score is a no-op if it already exists. Followers who already received the push see no duplicate; followers who hadn't yet received it get it on the retry.

Counter Sync Worker

If the Counter Sync Worker fails to flush to PostgreSQL (e.g., DB connectivity issue), the Redis counters continue accumulating. The worker retries on its next scheduled run (30 seconds). Redis counter keys have no TTL, so data is not lost during outage windows of reasonable length (hours). If Redis itself fails, in-flight counter increments are lost — this is an accepted tradeoff (like counts may be slightly undercounted after a Redis failure, not a data integrity issue for this product).

PhotoDNA (CSAM Scan)

PhotoDNA being unreachable is a hard blocker — posts cannot be published without a successful scan. The worker retries up to 3 times with exponential backoff. After 3 failures, the post remains in processing and an alert fires. This means a PhotoDNA outage directly impacts post publication latency for all users. Mitigation: monitor PhotoDNA API health in the alerting dashboard; consider a secondary provider (AWS Rekognition for CSAM) as a fallback if PhotoDNA SLA proves insufficient.

What happens if PhotoDNA returns a CSAM match? The Media Worker immediately moves the raw file to the restricted (non-public, non-CDN) bucket, marks the post removed_csam, writes an entry to the moderation audit log, and enqueues a report to NCMEC (as legally required in the US under CIPA). The post is never published. The user account is flagged for review. This sequence is not retriable — it is final.

FCM (Push Notifications)

FCM call failures are retried up to 3 times by the River job. After 3 failures, the notification is dropped and logged. Push delivery is best-effort — a missed push notification is a UX degradation, not a data integrity issue. In-app notifications are always written to PostgreSQL synchronously with the triggering event, so users see them on next app open even if push fails.

GDPR Deletion Pipeline

What happens if the daily deletion job fails partway through processing a user's deletion request? The job is idempotent: it checks gdpr_deletion_requested_at and the current state of each PII field. Fields already anonymized are skipped. Media file deletion calls to GCS are also idempotent (deleting a non-existent object is a no-op). The job will pick up the incomplete deletion on its next run. This ensures eventual consistency of deletion without requiring a complex saga.

Input Validation

All user-supplied text is validated at the service layer: length limits enforced, HTML stripped, null bytes rejected. Invalid input returns a validation error immediately. Media uploads are validated by magic-byte inspection in the Media Worker — a client-supplied content type is not trusted. If a file's magic bytes don't match a supported media type (JPEG, PNG, HEIC, WebP, MP4, MOV), the job fails immediately without retrying, and the post is marked failed_validation.

Pre-Launch Verification

  • PhotoDNA: Test against sandbox endpoint with known-safe and known-flagged test hashes in CI.
  • FCM: Use Firebase's test message feature and a staging device token pool.
  • Cloud SQL failover: Trigger a manual failover in staging; verify that the application reconnects within the pgx connection pool's retry window (30-second max reconnect time).
  • River dead-letter queue: Inject deliberately malformed jobs in staging; verify dead-letter routing and alerting fire correctly.
  • GDPR deletion: Run the full deletion pipeline against a synthetic test user in staging; verify all PII fields are overwritten and GCS objects are deleted.

Risks and Mitigations

Risk 1: Redis memory exhaustion from feed queue growth Component: Feed Service + Fanout Worker (per-user Redis sorted sets) Impact: Redis OOM causes Memorystore failover or eviction of feed data, degrading feed freshness for all users. Mitigation: Feed sorted sets are hard-capped at 1,000 entries per user and have a 7-day inactivity TTL. Redis memory usage is monitored via Cloud Monitoring with an alert at 75% capacity. Memorystore capacity can be scaled up with no downtime. At the early growth stage, 10 GB is sufficient for ~1M active users with 1,000-entry feed queues (~200 bytes/entry = ~200 MB for 1M users, leaving ample headroom).

Risk 2: Cloud SQL becoming a write bottleneck on the social graph at scale Component: Social Service (follow/follower queries), Fanout Worker (follower list fetches) Impact: Slow follower list reads increase fanout latency; slow write throughput degrades post creation and comment writing. Mitigation: The follows table has indexes on both follower_id and followee_id. Hot follow-relationship lookups are cached in Redis. Cloud SQL read replicas can be added and query-routed to without application changes (Cloud SQL proxy handles this). If write throughput becomes the bottleneck, the follows table is the primary candidate for migration to a dedicated graph store (e.g., PostgreSQL with a recursive CTE pattern or a dedicated service backed by a sparse adjacency list). This is a Phase 2 concern.

Risk 3: River job queue becoming a throughput bottleneck at peak Component: River (pgqueue) — shares Cloud SQL with the primary application Impact: High job queue volume (post creation spikes, viral posts triggering mass fanout) contends with primary OLTP queries on the same PostgreSQL instance, degrading API response times. Mitigation: River jobs are stored in a separate PostgreSQL schema on the same instance. The SKIP LOCKED pattern minimizes contention. A dedicated Cloud SQL instance for River (separate from the primary OLTP instance) is the first escalation path — this is a config change, not an architecture change. Monitor Cloud SQL CPU and pg_stat_activity for lock contention; alert threshold at 80% CPU.

Risk 4: PhotoDNA outage blocking all post publication Component: Media Processing Worker → PhotoDNA integration Impact: If PhotoDNA is unavailable for an extended period, no new posts can be published — a direct product outage for content creation. Mitigation: Monitor PhotoDNA API availability via an external health check (separate from the application path). Negotiate a service SLA with the PhotoDNA vendor. Pre-evaluate AWS Rekognition's content moderation API as a secondary CSAM detection provider that can be switched to via a feature flag if PhotoDNA is down for more than 15 minutes. Document the manual override procedure (for a clear false-positive outage scenario) requiring two-person sign-off.

Risk 5: GDPR/compliance gap in EU data residency routing Component: API Gateway (regional routing logic), Cloud SQL (EU instance writes) Impact: European user data written to the US-region Cloud SQL instance creates a GDPR data residency violation, with potential regulatory fines. Mitigation: Regional routing is enforced at the API Gateway layer based on a flag set at registration time (IP-geolocation on signup). The EU-flag is immutable after account creation. Integration tests in CI verify that a synthetic EU-flagged user's write path targets the EU Cloud SQL instance. A quarterly compliance audit (even a self-audit by the team) verifies no cross-region data leakage. Consider using Google Cloud's Assured Workloads for the EU region to get additional policy enforcement.

Implementation Plan

Implementation Plan

Environment Setup

What you'll need

Before any code gets written, you'll need a handful of accounts set up and configured. Getting these right upfront means your coding agent has everything it needs to build without hitting dead ends mid-phase.

Accounts to create

  • Google Cloud Platform — cloud.google.com/free — hosts everything: your database, cache, containers, media storage, CDN, and secrets. This is the core of the infrastructure.
  • Firebase — firebase.google.com — handles phone number verification (OTP) for user registration and push notifications to iOS and Android devices. You'll connect this to your GCP project.
  • Microsoft Azure (for PhotoDNA) — customervoice.microsoft.com/Pages/PhotoDNA.aspx — provides the CSAM hash-scanning API required before any media can be published. You'll need to apply for API access; do this early as approval may take time.
  • GitHub — github.com — hosts your code and runs the automated build, test, and deploy pipeline via GitHub Actions.

Before you start

Google Cloud setup:

  • Create a new GCP project and enable billing.
  • Enable these APIs: Cloud SQL Admin, Kubernetes Engine, Cloud Storage, Cloud CDN, Memorystore for Redis, Secret Manager, Artifact Registry, Cloud Logging, and Workload Identity.
  • Create a GKE Autopilot cluster in us-central1 and a second one in europe-west1.
  • Create a Cloud SQL PostgreSQL 16 instance in us-central1 with HA enabled, private IP only, and point-in-time recovery on. Create a second instance in europe-west1 with the same settings. Create a database named chroma and a dedicated database user on each.
  • Create a Memorystore Redis 7 instance (Standard tier, 10 GB) in us-central1 and one in europe-west1.
  • Create two GCS buckets per region: one named chroma-quarantine-us / chroma-quarantine-eu (private) and one named chroma-delivery-us / chroma-delivery-eu (private access, CDN-served). Set a 24-hour object lifecycle rule on the quarantine buckets to auto-delete objects.
  • Set up Cloud CDN as a backend bucket in front of both delivery buckets. Note your CDN domain (e.g., cdn.chroma.app).
  • Create a service account for media operations with roles/storage.objectAdmin scoped to the quarantine and delivery buckets only.
  • Create a service account for each backend service and enable Workload Identity Federation so pods in GKE can authenticate without static credentials.
  • Store these secrets in Secret Manager: Cloud SQL credentials (both regions), the media service account signing key, your JWT RS256 private key, your FCM service account JSON, and your PhotoDNA API key.
  • Create a repository in Google Artifact Registry to store your Docker images.

Firebase setup:

  • In your Firebase console, link the Firebase project to your GCP project.
  • Enable Phone authentication under Authentication → Sign-in method.
  • Create an iOS app and an Android app in the Firebase project and download the config files (GoogleService-Info.plist and google-services.json) — these go into the mobile app.
  • Enable Firebase Cloud Messaging. Download the FCM service account JSON key and store it in Secret Manager.

PhotoDNA setup:

  • Once API access is approved, store your API key in Secret Manager.
  • Note the sandbox endpoint URL for use in CI tests.

GitHub setup:

  • Create a repository for the project.
  • In repository Settings → Secrets and variables → Actions, add all environment variables listed below.
  • Set up two GitHub Actions environments: staging (auto-deploy on merge to main) and production (requires manual approval).

Environment variables

Google Cloud / Infrastructure:

  • GCP_PROJECT_ID — GCP console → project selector, top of the page
  • GCP_REGION_PRIMARY — set to us-central1
  • GCP_REGION_EU — set to europe-west1
  • ARTIFACT_REGISTRY_REPO — Artifact Registry → your repository → copy the full path (e.g., us-central1-docker.pkg.dev/your-project/chroma)
  • GKE_CLUSTER_PRIMARY — GKE console → cluster name for us-central1
  • GKE_CLUSTER_EU — GKE console → cluster name for europe-west1
  • WORKLOAD_IDENTITY_PROVIDER — IAM → Workload Identity Federation → provider resource name

Database:

  • DB_HOST_PRIMARY — Cloud SQL → your us-central1 instance → Private IP address
  • DB_HOST_EU — Cloud SQL → your europe-west1 instance → Private IP address
  • DB_NAME — chroma (the database name you created)
  • DB_USER — the dedicated database user you created
  • DB_PASSWORD_SECRET — Secret Manager → the secret name holding the DB password (the app reads this at runtime via Workload Identity)

Redis:

  • REDIS_HOST_PRIMARY — Memorystore → your us-central1 instance → IP address
  • REDIS_HOST_EU — Memorystore → your europe-west1 instance → IP address
  • REDIS_PORT — 6379

Media / Storage:

  • GCS_QUARANTINE_BUCKET_US — chroma-quarantine-us
  • GCS_QUARANTINE_BUCKET_EU — chroma-quarantine-eu
  • GCS_DELIVERY_BUCKET_US — chroma-delivery-us
  • GCS_DELIVERY_BUCKET_EU — chroma-delivery-eu
  • CDN_BASE_URL — your Cloud CDN domain, e.g., https://cdn.chroma.app
  • MEDIA_SA_EMAIL — IAM → Service Accounts → the media service account email

Auth:

  • JWT_PRIVATE_KEY_SECRET — Secret Manager → the secret name for your RS256 private key
  • JWT_PUBLIC_KEY_SECRET — Secret Manager → the secret name for your RS256 public key
  • FIREBASE_PROJECT_ID — Firebase console → Project settings → Project ID

FCM:

  • FCM_SA_SECRET — Secret Manager → the secret name for your FCM service account JSON

PhotoDNA:

  • PHOTODNA_API_KEY_SECRET — Secret Manager → the secret name for your PhotoDNA API key
  • PHOTODNA_API_URL — provided by Microsoft on API approval (sandbox URL for CI: provided in your PhotoDNA developer account)

CI/CD:

  • GITHUB_ACTIONS_SA_KEY — the service account key used by GitHub Actions to authenticate with GCP (IAM → create a CI service account with Artifact Registry writer + GKE developer roles, download JSON key)

Phases

Phase 1: Data Layer and Schema

This phase establishes every persistent data structure the application depends on — PostgreSQL schema, Redis key conventions, and GCS bucket layout — before any service is built. Getting the schema right here means no phase ever migrates a table another phase is actively using. The River job queue tables are included here so the worker phases can assume a ready queue.

Contracts

Module: internal/db/schema

This module contains all SQL migration files (one file per logical group, numbered sequentially) and a Go migration runner. No application code calls this module at runtime — it runs as an init container or a one-off job before service startup.

Migration files and what they create:

  • 0001_users.sql — users table with columns: id (UUID PK), username (VARCHAR(30), unique, not null), phone_hash (VARCHAR(64), unique), profile_pic_url (TEXT, nullable), bio (VARCHAR(150), nullable), is_private (BOOLEAN, default false), is_deleted (BOOLEAN, default false), gdpr_deletion_requested_at (TIMESTAMPTZ, nullable), created_at (TIMESTAMPTZ, not null, default now()). Indexes: lowercase GIN index on username for case-insensitive uniqueness, tsvector GIN index on username || ' ' || display_name for full-text search. Add display_name VARCHAR(50), nullable, to support FTS alongside username.
  • 0002_posts.sql — posts table with columns: id (UUID PK), author_id (UUID FK → users, not null), media_type (ENUM photo/video), state (ENUM pending/processing/published/removed_csam/removed_moderation/deleted/failed_upload/failed_validation), caption (TEXT, nullable), location (VARCHAR(100), nullable), like_count (BIGINT, default 0), comment_count (BIGINT, default 0), is_reel (BOOLEAN, default false), created_at (TIMESTAMPTZ, not null, default now()), published_at (TIMESTAMPTZ, nullable). Indexes: on author_id, on created_at DESC, on state where state = 'published'.
  • 0003_follows.sql — follows table with composite PK (follower_id, followee_id), state (ENUM pending/accepted), created_at (TIMESTAMPTZ, not null, default now()). Indexes: on follower_id, on followee_id, on created_at DESC.
  • 0004_comments.sql — comments table: id (UUID PK), post_id (UUID FK → posts, indexed), author_id (UUID FK → users), body (TEXT, not null, max 2200), parent_id (UUID FK → comments, nullable), created_at (TIMESTAMPTZ, not null, default now()).
  • 0005_likes.sql — likes table: composite PK (post_id, user_id), created_at (TIMESTAMPTZ, not null, default now()). Partitioned by hash on post_id, 8 partitions. Index on user_id for GDPR deletion queries.
  • 0006_hashtags.sql — hashtags table: id (UUID PK), tag (TEXT, unique, not null), created_at (TIMESTAMPTZ). post_hashtags junction: post_id (UUID FK → posts), hashtag_id (UUID FK → hashtags), composite PK.
  • 0007_notifications.sql — notifications table: id (UUID PK), recipient_id (UUID FK → users), type (ENUM like/comment/follow/mention), actor_id (UUID FK → users, nullable), post_id (UUID FK → posts, nullable), read (BOOLEAN, default false), created_at (TIMESTAMPTZ, not null, default now()). Index on recipient_id, created_at DESC.
  • 0008_device_tokens.sql — device_tokens table: id (UUID PK), user_id (UUID FK → users), token (TEXT, not null), platform (ENUM ios/android), created_at (TIMESTAMPTZ), updated_at (TIMESTAMPTZ). Unique index on token.
  • 0009_gdpr_deletion_requests.sql — gdpr_deletion_requests table: user_id (UUID PK), requested_at (TIMESTAMPTZ), delete_posts (BOOLEAN), processed_at (TIMESTAMPTZ, nullable).
  • 0010_river.sql — River job queue tables, created by running River's built-in schema migration via the River Go client. This populates the river_jobs, river_job_completions, and related tables in a river schema.
  • 0011_media_urls.sql — adds media_urls (JSONB, nullable) to the posts table — stores the map of variant name → CDN URL after processing (e.g., {"thumbnail": "https://cdn.chroma.app/...", "feed": "...", "full": "..."}).

Module: internal/db/redis

Documents Redis key schemas as Go constants. No logic — purely the naming conventions every service imports.

RedisKeyFeedQueue(userID string) string
  returns: "feed:v1:{userID}"
  type: sorted set, score = algorithmic rank (float64), member = post ID (string)
  cap: 1000 entries, 7-day inactivity TTL

RedisKeyCounterLikes(postID string) string
  returns: "counter:likes:{postID}"
  type: string (integer)

RedisKeyCounterComments(postID string) string
  returns: "counter:comments:{postID}"
  type: string (integer)

RedisKeyCounterDirty() string
  returns: "counter:dirty"
  type: set, members = post IDs with unflushed counter changes

RedisKeySession(tokenID string) string
  returns: "session:v1:{tokenID}"
  type: string (JSON-encoded session payload), TTL = 15 minutes

RedisKeyActiveUsers() string
  returns: "active:users:v1"
  type: sorted set, score = Unix timestamp of last activity, member = user ID

RedisKeyFollowCache(followerID, followeeID string) string
  returns: "follow:v1:{followerID}:{followeeID}"
  type: string ("1" = follows, "0" = does not follow), TTL = 5 minutes

RedisKeyHashtagCount(tag string) string
  returns: "hashtag:count:v1:{tag}"
  type: string (integer)

RedisKeyTrendingReels() string
  returns: "reels:trending:v1"
  type: sorted set, score = engagement velocity, member = post ID

RedisKeyEngagementVelocity(postID string) string
  returns: "engagement:velocity:v1:{postID}"
  type: string (float64), no TTL

RedisKeyRelationshipProximity(userID string) string
  returns: "proximity:v1:{userID}"
  type: set, members = user IDs the user has interacted with (capped at 200)

RedisKeyContentPreference(userID string) string
  returns: "pref:content:v1:{userID}"
  type: string ("photo" | "video" | ""), updated by Feed Service on interaction

Deliverables:

  • All SQL migration files created and validated against a local PostgreSQL instance.
  • Redis key constant module compiles with no dependencies on other application packages.
  • Running the migration runner against a fresh database produces all tables, indexes, partitions, and the River schema with no errors.
  • A docker-compose.yml at the repo root starts PostgreSQL 16, Redis 7, and MinIO (GCS-compatible) for local development, with migrations applied automatically on startup.

Phase 2: Shared Infrastructure Libraries

This phase builds the internal packages every service imports: database connection management, Redis client setup, secret loading, structured logging, error types, and the JWT keypair loader. Nothing in this phase is user-facing — it's the foundation every service in later phases stands on.

Contracts

Module: internal/platform/secrets

Load(ctx context.Context, secretName string) (string, error)
  Fetches the named secret from Google Secret Manager using the ambient Workload Identity credential.
  Returns the secret payload as a plain string.
  Error: returns SecretNotFoundError if the secret does not exist; SecretAccessError on permission or transport failure.
type SecretNotFoundError struct{ Name string }
type SecretAccessError struct{ Name string; Cause error }

Module: internal/platform/db

Connect(ctx context.Context, cfg DBConfig) (*pgxpool.Pool, error)
  Opens a pgx connection pool to the Cloud SQL instance.
  DBConfig fields: Host string, Port int, Name string, User string, PasswordSecretName string.
  Resolves the password by calling secrets.Load before opening the pool.
  Pool settings: min 2 connections, max 20 connections, 30-second max connect timeout.
  Error: returns DBConnectionError wrapping the underlying cause.

type DBConfig struct {
  Host               string
  Port               int
  Name               string
  User               string
  PasswordSecretName string
}

type DBConnectionError struct{ Cause error }

Module: internal/platform/cache

Connect(ctx context.Context, cfg RedisConfig) (*redis.Client, error)
  Opens a Redis client to the Memorystore instance.
  RedisConfig fields: Host string, Port int.
  Returns a connected client ready for use.
  Error: returns CacheConnectionError on failure.

type RedisConfig struct{ Host string; Port int }
type CacheConnectionError struct{ Cause error }

Module: internal/platform/auth

LoadKeyPair(ctx context.Context) (privateKey *rsa.PrivateKey, publicKey *rsa.PublicKey, error)
  Loads the RS256 private key from the secret named in JWT_PRIVATE_KEY_SECRET and the public key from JWT_PUBLIC_KEY_SECRET.
  Returns parsed RSA key objects ready for signing and verification.
  Error: KeyLoadError if the secret is missing or the PEM cannot be parsed.

IssueAccessToken(userID, tokenID string, privateKey *rsa.PrivateKey) (string, error)
  Issues a signed RS256 JWT with claims: sub=userID, jti=tokenID, exp=15 minutes from now, iat=now.
  Returns the signed token string.

IssueRefreshToken(userID string) (token string, tokenID string, error)
  Generates a cryptographically random 32-byte token ID, base64url-encodes it.
  Returns the token string (stored in PostgreSQL) and its ID.

ValidateAccessToken(tokenString string, publicKey *rsa.PublicKey) (claims JWTClaims, error)
  Parses and validates the JWT signature, expiry, and issuer.
  Error: TokenExpiredError, TokenInvalidError.

type JWTClaims struct {
  UserID  string
  TokenID string
  IssuedAt time.Time
  ExpiresAt time.Time
}

type KeyLoadError struct{ Cause error }
type TokenExpiredError struct{}
type TokenInvalidError struct{ Reason string }

Module: internal/platform/logger

New(serviceName string) *slog.Logger
  Returns a structured JSON logger with the service name attached as a default field.
  Output goes to stdout (Cloud Logging ingests from stdout on GKE).

Module: internal/platform/apierror

Defines the canonical error response shape returned by all services:

type APIError struct {
  Code    string `json:"code"`    // machine-readable, e.g. "VALIDATION_ERROR"
  Message string `json:"message"` // human-readable
}

Standard codes used across all services: VALIDATION_ERROR, AUTH_ERROR, NOT_FOUND, FORBIDDEN, CONFLICT, RATE_LIMITED, INTERNAL_ERROR, SERVICE_UNAVAILABLE.

WriteError(w http.ResponseWriter, status int, code, message string)
  Writes a JSON APIError response with the given HTTP status.

Module: internal/platform/sanitize

Text(input string, maxLen int) (string, error)
  Strips HTML tags, rejects null bytes, trims whitespace, enforces maxLen.
  Returns sanitized string or ValidationError if input exceeds maxLen after stripping.

Username(input string) (string, error)
  Lowercases input, enforces alphanumeric + underscore only, rejects empty and >30 chars.
  Returns ValidationError with a specific reason string on any violation.

type ValidationError struct{ Field string; Reason string }

Deliverables:

  • All modules compile cleanly with no circular imports.
  • Unit tests cover: secret loading (mocked Secret Manager), JWT issue/validate round-trip, token expiry detection, text sanitization edge cases (null bytes, HTML, length boundary), username validation rules.
  • docker-compose.yml local stack confirmed working with the db and cache Connect functions.

Phase 3: Social Service — Registration, Auth, Profiles, and Follow Graph

This phase delivers user accounts, authentication, the follow graph, user search, and the block relationship. It is the first runnable HTTP service and the authority on user identity for every other service.

Contracts

Module: services/social/handler

All handlers read the authenticated user ID from the request context (set by middleware that validates the JWT). Unauthenticated endpoints (register, login) bypass this middleware.

POST /v1/auth/register
  Body: { firebase_id_token: string, username: string, display_name: string }
  1. Calls social/auth.VerifyFirebaseToken(ctx, firebase_id_token) → phone (string), error.
  2. Calls sanitize.Username(username) and sanitize.Text(display_name, 50).
  3. Calls social/store.CreateUser(ctx, CreateUserParams) → User, error.
  4. Issues access + refresh tokens via platform/auth.
  5. Stores refresh token in PostgreSQL via social/store.CreateRefreshToken.
  6. Returns 201: { user: UserResponse, access_token: string, refresh_token: string }.
  Error: 409 CONFLICT if username or phone already taken; 400 VALIDATION_ERROR on bad input; 401 AUTH_ERROR if Firebase token invalid.

POST /v1/auth/login
  Body: { firebase_id_token: string }
  1. Verifies Firebase token, extracts phone.
  2. Fetches user by phone_hash via social/store.GetUserByPhoneHash.
  3. Issues new token pair, stores refresh token, invalidates old refresh tokens for this device.
  4. Returns 200: { user: UserResponse, access_token: string, refresh_token: string }.
  Error: 404 NOT_FOUND if no account for that phone; 401 AUTH_ERROR on bad token.

POST /v1/auth/refresh
  Body: { refresh_token: string }
  1. Looks up the refresh token record via social/store.GetRefreshToken.
  2. Validates not revoked, not expired (30 days).
  3. Issues new access token.
  4. Returns 200: { access_token: string }.
  Error: 401 AUTH_ERROR if token missing, revoked, or expired.

POST /v1/auth/logout
  Authenticated. Revokes the current refresh token via social/store.RevokeRefreshToken(ctx, tokenID).
  Returns 204.

GET /v1/users/:username
  Returns public profile. If requester does not follow target and target is private, returns limited view (no post count).
  Calls social/store.GetUserByUsername → User.
  Returns 200: UserResponse.

PATCH /v1/users/me
  Authenticated. Body fields (all optional): display_name, bio, profile_pic_url, is_private.
  Sanitizes each field before write.
  Returns 200: UserResponse.

POST /v1/users/:userID/follow
  Authenticated. If target is private, creates a pending follow; if public, creates an accepted follow immediately.
  Calls social/store.CreateFollow(ctx, followerID, followeeID) → Follow, error.
  Updates active-user Redis set for follower via social/cache.TouchActiveUser(ctx, followerID).
  Returns 200: { state: "pending" | "accepted" }.
  Error: 409 CONFLICT if follow already exists; 403 FORBIDDEN if blocked.

DELETE /v1/users/:userID/follow
  Authenticated. Removes the follow relationship.
  Invalidates the Redis follow cache key for this pair.
  Returns 204.

POST /v1/users/:userID/follow/approve  (for private accounts)
  Authenticated. Target must be the current user (the followee).
  Transitions follow state from pending → accepted.
  Returns 200: { state: "accepted" }.

GET /v1/users/:userID/followers
  Paginated (cursor: created_at). Returns list of UserResponse.
  Query params: cursor (opaque string, base64 of created_at timestamp), limit (max 50, default 20).

GET /v1/users/:userID/following
  Same pagination shape as followers.

POST /v1/users/:userID/block
  Authenticated. Creates block record, removes any existing follow relationship in both directions.
  Returns 204.

DELETE /v1/users/:userID/block
  Authenticated. Removes block.
  Returns 204.

GET /v1/users/search
  Query param: q (string, 3–30 chars), cursor, limit (max 30).
  Calls social/store.SearchUsers(ctx, q, cursor, limit) which runs PostgreSQL FTS on the tsvector column.
  Blocked users are excluded from results.
  Returns 200: { users: []UserResponse, next_cursor: string | null }.

Module: services/social/auth

VerifyFirebaseToken(ctx context.Context, idToken string) (phone string, error)
  Fetches Firebase public keys (cached in memory, refreshed on 401 from Firebase).
  Validates the ID token JWT signature and expiry.
  Extracts and returns the E.164 phone number from the token claims.
  Error: FirebaseTokenError if token is invalid or expired.

type FirebaseTokenError struct{ Reason string }

Module: services/social/store

All functions accept a *pgxpool.Pool. Return types use the shared model types defined in internal/models.

CreateUser(ctx, CreateUserParams) (models.User, error)
CreateUserParams: { Username, DisplayName, PhoneHash string }

GetUserByPhoneHash(ctx, phoneHash string) (models.User, error)
GetUserByUsername(ctx, username string) (models.User, error)
GetUserByID(ctx, userID string) (models.User, error)

UpdateUser(ctx, userID string, UpdateUserParams) (models.User, error)
UpdateUserParams: all fields optional pointers: DisplayName, Bio, ProfilePicURL *string, IsPrivate *bool

CreateRefreshToken(ctx, userID, tokenID string, expiresAt time.Time) error
GetRefreshToken(ctx, tokenID string) (models.RefreshToken, error)
RevokeRefreshToken(ctx, tokenID string) error

CreateFollow(ctx, followerID, followeeID string) (models.Follow, error)
DeleteFollow(ctx, followerID, followeeID string) error
ApproveFollow(ctx, followerID, followeeID string) error
GetFollow(ctx, followerID, followeeID string) (models.Follow, error)

ListFollowers(ctx, userID string, cursor time.Time, limit int) ([]models.User, error)
ListFollowing(ctx, userID string, cursor time.Time, limit int) ([]models.User, error)

CreateBlock(ctx, blockerID, blockedID string) error
DeleteBlock(ctx, blockerID, blockedID string) error
IsBlocked(ctx, userAID, userBID string) (bool, error)

SearchUsers(ctx, query string, cursor time.Time, limit int) ([]models.User, error)
  Runs: SELECT ... FROM users WHERE to_tsvector('english', username || ' ' || display_name) @@ plainto_tsquery('english', $1) AND NOT is_deleted ORDER BY created_at DESC

Module: services/social/cache

TouchActiveUser(ctx context.Context, rdb *redis.Client, userID string) error
  Adds/updates userID in the active-users sorted set with score = current Unix timestamp.

IsActiveUser(ctx, rdb, userID string) (bool, error)
  Returns true if the user appears in the active-users sorted set with a score within the last 48 hours.

SetFollowCache(ctx, rdb, followerID, followeeID string, follows bool) error
GetFollowCache(ctx, rdb, followerID, followeeID string) (follows bool, found bool, error)
InvalidateFollowCache(ctx, rdb, followerID, followeeID string) error

Module: internal/models

Defines shared Go structs for all database entities. All other services import from here — never define entity structs locally.

type User struct {
  ID                      string
  Username                string
  DisplayName             string
  PhoneHash               string
  ProfilePicURL           *string
  Bio                     *string
  IsPrivate               bool
  IsDeleted               bool
  GDPRDeletionRequestedAt *time.Time
  CreatedAt               time.Time
}

type Follow struct {
  FollowerID  string
  FolloweeID  string
  State       string // "pending" | "accepted"
  CreatedAt   time.Time
}

type RefreshToken struct {
  TokenID   string
  UserID    string
  Revoked   bool
  ExpiresAt time.Time
}

type Post struct {
  ID           string
  AuthorID     string
  MediaType    string // "photo" | "video"
  State        string
  Caption      *string
  Location     *string
  LikeCount    int64
  CommentCount int64
  IsReel       bool
  MediaURLs    map[string]string // variant → CDN URL
  CreatedAt    time.Time
  PublishedAt  *time.Time
}

type Comment struct {
  ID        string
  PostID    string
  AuthorID  string
  Body      string
  ParentID  *string
  CreatedAt time.Time
}

type Notification struct {
  ID          string
  RecipientID string
  Type        string
  ActorID     *string
  PostID      *string
  Read        bool
  CreatedAt   time.Time
}

type DeviceToken struct {
  ID        string
  UserID    string
  Token     string
  Platform  string // "ios" | "android"
  CreatedAt time.Time
  UpdatedAt time.Time
}

Response shape: UserResponse

type UserResponse struct {
  ID            string  `json:"id"`
  Username      string  `json:"username"`
  DisplayName   string  `json:"display_name"`
  ProfilePicURL *string `json:"profile_pic_url"`
  Bio           *string `json:"bio"`
  IsPrivate     bool    `json:"is_private"`
  CreatedAt     string  `json:"created_at"` // RFC3339
}

Deliverables:

  • Social Service starts cleanly and connects to PostgreSQL and Redis.
  • All auth endpoints return correct tokens; a round-trip register → login → refresh → logout works end-to-end.
  • Follow/unfollow/block operations correctly update both PostgreSQL and the Redis follow cache.
  • User search returns results ranked by FTS relevance, excluding blocked users.
  • Integration tests (using testcontainers-go with PostgreSQL + Redis) pass for all store and cache functions.

Phase 4: Content Service — Post Lifecycle and Media Ingestion

This phase delivers the full post creation lifecycle (signed URL generation, Phase 2 confirmation, state machine), hashtag extraction, post retrieval, and the like/comment write paths. It also creates the River job queue integration used by all subsequent worker phases.

Contracts

Module: services/content/handler

POST /v1/posts/upload-url
  Authenticated. Body: { media_type: "photo" | "video", filename: string }
  1. Validates media_type is one of the two allowed values.
  2. Generates a UUID post ID.
  3. Creates a pending post record via content/store.CreatePendingPost(ctx, postID, authorID, mediaType).
  4. Calls content/gcs.SignedUploadURL(ctx, quarantineBucket, objectKey, mediaType) → url string, error.
     objectKey format: "{authorID}/{postID}/{filename}"
  5. Returns 200: { post_id: string, upload_url: string, expires_in_seconds: 900 }.

POST /v1/posts/:postID/confirm-upload
  Authenticated. Body: { caption: string | null, location: string | null, is_reel: bool }
  1. Verifies post belongs to authenticated user and is in pending state.
  2. Validates caption (max 2200, sanitize.Text) and location (max 100, sanitize.Text).
  3. Calls content/gcs.ObjectExists(ctx, quarantineBucket, objectKey) → bool, error. If false → 400 VALIDATION_ERROR.
  4. Extracts hashtags from caption via content/hashtags.Extract(caption) → []string.
  5. Calls content/store.ConfirmUpload(ctx, postID, caption, location, isReel, hashtags).
     This writes hashtags to PostgreSQL and transitions post state to "processing".
  6. Enqueues a River job of type "process_media" with payload { post_id, author_id, media_type, object_key }.
  7. Returns 200: { post_id: string, state: "processing" }.

GET /v1/posts/:postID
  Returns post if published. If author is private and requester does not follow them, returns 403.
  Fetches like_count from Redis (content/cache.GetLikeCount) before responding; falls back to PostgreSQL value.
  Returns 200: PostResponse.

GET /v1/users/:userID/posts
  Paginated profile feed. Returns only published posts.
  Cursor: published_at timestamp. Limit max 30.
  Same privacy check as single post.
  Returns 200: { posts: []PostResponse, next_cursor: string | null }.

POST /v1/posts/:postID/likes
  Authenticated. Body: empty.
  1. Checks the likes table for existing like (point lookup on composite key).
  2. If already liked: returns 409 CONFLICT.
  3. Inserts into likes table.
  4. Calls content/cache.IncrementLikeCounter(ctx, postID).
  5. Enqueues a River job "notify_like" with payload { post_id, liker_id, post_author_id }.
  6. Returns 201.
  Error: 404 if post not found or not published.

DELETE /v1/posts/:postID/likes
  Authenticated. Deletes like row. Calls content/cache.DecrementLikeCounter(ctx, postID).
  Returns 204.

POST /v1/posts/:postID/comments
  Authenticated. Body: { body: string, parent_id: string | null }.
  1. Sanitizes body (max 2200 chars).
  2. Validates parent_id if provided — parent must belong to same post and have no parent itself (one level deep only).
  3. Inserts comment into PostgreSQL synchronously (comment data is written to DB, not deferred).
  4. Calls content/cache.IncrementCommentCounter(ctx, postID).
  5. Enqueues River job "notify_comment".
  6. Returns 201: CommentResponse.

GET /v1/posts/:postID/comments
  Paginated (cursor: created_at, limit max 50).
  Returns top-level comments first, replies nested under their parent (one level only).
  Counts fetched from PostgreSQL comment_count (authoritative for display in this view).
  Returns 200: { comments: []CommentResponse, next_cursor: string | null }.

DELETE /v1/posts/:postID/comments/:commentID
  Authenticated. Author of comment or post owner can delete.
  Deletes from PostgreSQL. Calls content/cache.DecrementCommentCounter(ctx, postID).
  Returns 204.

DELETE /v1/posts/:postID
  Authenticated. Only post author.
  Transitions post state to "deleted". Does not delete GCS objects synchronously (handled by GDPR/cleanup worker).
  Returns 204.

PATCH /v1/internal/posts/:postID/state  (internal endpoint, not exposed through Kong)
  Called by Media Processing Worker only. Body: { state: string, media_urls: map[string]string }.
  Updates post state and media_urls. If state = "published", sets published_at = now(), enqueues "post_published" River job.
  Returns 200.

Module: content/store

Same error handling contract as Phase 3's services/social/store (pgxpool, same error wrapping pattern).

CreatePendingPost(ctx, postID, authorID, mediaType string) error
ConfirmUpload(ctx, postID, caption, location *string, isReel bool, hashtags []string) error
  Writes post fields, upserts hashtags, writes post_hashtags rows, transitions state to "processing".
GetPost(ctx, postID string) (models.Post, error)
ListUserPosts(ctx, userID string, cursor time.Time, limit int) ([]models.Post, error)
UpdatePostState(ctx, postID, state string, mediaURLs map[string]string) error
CreateLike(ctx, postID, userID string) error
DeleteLike(ctx, postID, userID string) error
HasLiked(ctx, postID, userID string) (bool, error)
CreateComment(ctx, comment models.Comment) (models.Comment, error)
GetComment(ctx, commentID string) (models.Comment, error)
ListComments(ctx, postID string, cursor time.Time, limit int) ([]models.Comment, error)
DeleteComment(ctx, commentID string) error
MarkStalePendingPostsFailed(ctx context.Context, olderThan time.Duration) error
  Used by the hourly cleanup job. Transitions posts in "pending" state older than olderThan to "failed_upload".

Module: content/cache

Same error handling contract as Phase 2's services/social/cache.

IncrementLikeCounter(ctx, rdb, postID string) error
  INCR on RedisKeyCounterLikes(postID); SADD postID to RedisKeyCounterDirty().

DecrementLikeCounter(ctx, rdb, postID string) error
  DECR on RedisKeyCounterLikes(postID); SADD to dirty set.

GetLikeCount(ctx, rdb, postID string) (count int64, found bool, error)
  GET on RedisKeyCounterLikes(postID). Returns found=false if key missing.

IncrementCommentCounter(ctx, rdb, postID string) error
DecrementCommentCounter(ctx, rdb, postID string) error

Module: content/gcs

SignedUploadURL(ctx context.Context, bucket, objectKey, contentType string) (url string, error)
  Generates a V4 GCS signed URL for PUT, scoped to the given bucket and object key, valid for 15 minutes.
  contentType is validated to be one of: "image/jpeg", "image/png", "image/heic", "image/webp", "video/mp4", "video/quicktime".
  Error: GCSSignError wrapping the underlying cause.

ObjectExists(ctx, bucket, objectKey string) (bool, error)
  Checks if the object is present in the given bucket.
  Error: GCSAccessError wrapping the underlying cause.

type GCSSignError struct{ Cause error }
type GCSAccessError struct{ Cause error }

Module: content/hashtags

Extract(caption string) []string
  Parses all #tag occurrences from caption using a regex.
  Tags are lowercased, deduplicated, max 30 chars each, max 30 tags returned per caption.
  Returns an empty slice (never nil) if no hashtags found.

River job payloads (defined in internal/jobs)

All jobs are structs with JSON tags. The internal/jobs package is the single source of truth for job type strings and payloads.

const (
  JobTypeProcessMedia    = "process_media"
  JobTypePostPublished   = "post_published"
  JobTypeNotifyFollowers = "notify_followers"
  JobTypeNotifyLike      = "notify_like"
  JobTypeNotifyComment   = "notify_comment"
  JobTypeNotifyMention   = "notify_mention"
)

type ProcessMediaPayload struct {
  PostID     string `json:"post_id"`
  AuthorID   string `json:"author_id"`
  MediaType  string `json:"media_type"`
  ObjectKey  string `json:"object_key"`
}

type PostPublishedPayload struct {
  PostID   string `json:"post_id"`
  AuthorID string `json:"author_id"`
}

type NotifyFollowersPayload struct {
  PostID   string `json:"post_id"`
  AuthorID string `json:"author_id"`
}

type NotifyLikePayload struct {
  PostID       string `json:"post_id"`
  LikerID      string `json:"liker_id"`
  PostAuthorID string `json:"post_author_id"`
}

type NotifyCommentPayload struct {
  PostID       string `json:"post_id"`
  CommentID    string `json:"comment_id"`
  AuthorID     string `json:"author_id"`
  PostAuthorID string `json:"post_author_id"`
}

type NotifyMentionPayload struct {
  PostID      string `json:"post_id"`
  ActorID     string `json:"actor_id"`
  MentionedID string `json:"mentioned_id"`
}

Module: internal/riverqueue

NewClient(pool *pgxpool.Pool) (*river.Client[pgx.Tx], error)
  Initializes and returns a River client configured against the given pool, using the "river" schema.
  The coding agent selects the appropriate River client constructor for the installed version.

Enqueue(ctx context.Context, client *river.Client[pgx.Tx], jobType string, payload any) error
  Enqueues a single job. Uses River's Insert method with the job type string and JSON-encoded payload.
  Error: EnqueueError wrapping the underlying cause.

type EnqueueError struct{ JobType string; Cause error }

Response shape: PostResponse

type PostResponse struct {
  ID           string            `json:"id"`
  Author       UserResponse      `json:"author"`
  MediaType    string            `json:"media_type"`
  MediaURLs    map[string]string `json:"media_urls"`
  Caption      *string           `json:"caption"`
  Location     *string           `json:"location,omitempty"`
  LikeCount    int64             `json:"like_count"`
  CommentCount int64             `json:"comment_count"`
  IsReel       bool              `json:"is_reel"`
  HasLiked     bool              `json:"has_liked"`
  PublishedAt  string            `json:"published_at"` // RFC3339
}

type CommentResponse struct {
  ID        string        `json:"id"`
  Author    UserResponse  `json:"author"`
  Body      string        `json:"body"`
  ParentID  *string       `json:"parent_id,omitempty"`
  CreatedAt string        `json:"created_at"` // RFC3339
}

Deliverables:

  • Signed upload URL endpoint returns a valid GCS pre-signed URL; a test upload to the quarantine bucket using that URL succeeds.
  • Full post creation round-trip (upload-url → direct GCS upload → confirm-upload) results in a post in processing state and a River job in the queue.
  • Like/unlike toggle correctly increments/decrements Redis counter and the dirty set.
  • Comment creation writes synchronously to PostgreSQL; pagination works correctly for threaded replies.
  • Hourly cleanup job marks stale pending posts as failed_upload.
  • Integration tests cover the two-phase post creation, like idempotency, and comment threading.

Phase 5: Media Processing Worker

This phase delivers the async worker that handles CSAM scanning, image resizing, video transcoding, and the final post-publish callback. It is the first River job consumer.

Contracts

Module: workers/media/worker

NewWorker(cfg MediaWorkerConfig) *MediaWorker
MediaWorkerConfig: {
  Pool         *pgxpool.Pool
  RiverClient  *river.Client[pgx.Tx]
  GCSClient    *storage.Client
  QuarantineBucket string
  DeliveryBucket   string
  ContentServiceURL string  // internal cluster URL for the state update callback
  PhotoDNAAPIURL   string
  PhotoDNAAPIKey   string
  Logger       *slog.Logger
}

Start(ctx context.Context, worker *MediaWorker) error
  Registers the process_media job handler and starts the River worker loop with 2 concurrent goroutines.
  Blocks until ctx is cancelled.

Module: workers/media/processor

This module contains the processing pipeline. Each step is a pure function where possible to allow unit testing without GCS/FFmpeg.

ProcessJob(ctx context.Context, cfg ProcessConfig, payload jobs.ProcessMediaPayload) error
  Orchestrates the full pipeline in order:
  1. DownloadRaw → 2. ScanCSAM → 3. ProcessMedia → 4. UploadVariants → 5. NotifyContentService → 6. DeleteRaw

DownloadRaw(ctx context.Context, gcsClient *storage.Client, bucket, objectKey string) ([]byte, error)
  Downloads the raw file bytes from the quarantine bucket.
  Error: DownloadError wrapping GCS cause.

ScanCSAM(ctx context.Context, cfg CSAMConfig, data []byte) (flagged bool, error)
  Calls the PhotoDNA API with the image/video data.
  Retries up to 3 times with increasing delay on transient failure.
  Returns flagged=true if PhotoDNA returns a CSAM match.
  Error: CSAMScanError if all retries fail (does not return flagged=true on error — error and flag are separate concerns).

type CSAMConfig struct{ APIURL, APIKey string }
type CSAMScanError struct{ Cause error }
type DownloadError struct{ Cause error }

ValidateMagicBytes(data []byte, declaredMediaType string) error
  Inspects the first bytes of data.
  Returns MagicByteMismatchError if the actual type does not match declaredMediaType.
  Supported: JPEG, PNG, HEIC, WebP (for "photo"); MP4, MOV (for "video").

type MagicByteMismatchError struct{ Declared, Detected string }

ProcessImage(data []byte) (variants map[string][]byte, error)
  Generates three variants using the imaging Go library:
  - "thumbnail": 150×150 center crop, JPEG
  - "feed": 1080px wide, proportional height, JPEG
  - "full": 1440px wide, proportional height, JPEG
  Returns a map of variant name → encoded bytes.
  Error: ImageProcessingError wrapping the underlying cause.

ProcessVideo(ctx context.Context, inputPath string) (variants map[string]string, thumbnailData []byte, previewData []byte, error)
  Writes input bytes to a temp file, runs FFmpeg 6.1 via exec.Command to produce:
  - "720p": H.264/AAC MP4 at 720p, output to temp file
  - "1080p": H.264/AAC MP4 at 1080p, output to temp file
  Extracts thumbnail frame at 1 second as JPEG (thumbnailData).
  Generates a 3-second WebP animation preview (previewData).
  Returns variant name → temp file path for upload; caller is responsible for temp file cleanup.
  Error: VideoProcessingError wrapping FFmpeg stderr output.

UploadVariants(ctx, gcsClient, deliveryBucket string, postID string, variants map[string][]byte) (cdnURLs map[string]string, error)
  Uploads each variant to the delivery bucket at path "{postID}/{variantName}".
  Constructs CDN URLs using CDN_BASE_URL env var: "{CDN_BASE_URL}/{postID}/{variantName}".
  Returns map of variant name → CDN URL.
  Error: UploadError wrapping GCS cause.

NotifyContentService(ctx context.Context, contentServiceURL, postID, state string, mediaURLs map[string]string) error
  Calls PATCH /v1/internal/posts/{postID}/state on the Content Service.
  Body: { state, media_urls }.
  Retries up to 3 times with increasing delay on transient failure.
  Error: NotifyError wrapping the HTTP cause.

DeleteRaw(ctx, gcsClient, quarantineBucket, objectKey string) error
  Deletes the raw file from the quarantine bucket.
  A missing object is treated as success (idempotent).

CSAM match handling (within ProcessJob)

If ScanCSAM returns flagged=true:

  1. Move raw file to the chroma-restricted bucket (a third GCS bucket, created in Phase 1 setup, private, no CDN).
  2. Call NotifyContentService with state = "removed_csam" and empty media_urls.
  3. Write an entry to the moderation_audit_log table (created in Phase 1 as 0012_moderation_audit.sql: id UUID PK, post_id UUID, event_type TEXT, created_at TIMESTAMPTZ).
  4. Do not enqueue NCMEC report in this phase — that is a manual operator action triggered by the audit log entry. (The ADD specifies NCMEC reporting; this implementation defers it to a manual step given no NCMEC API integration is defined in the constraint object.)
  5. Return nil (the job is complete, not a failure).

If ScanCSAM returns a CSAMScanError (API unreachable after retries): return the error so River retries the job. The post stays in processing state.

Deliverables:

  • Worker starts, connects to River, and polls for process_media jobs.
  • Image processing produces three correctly-sized JPEG variants; a test image is verifiably resized.
  • Video transcoding produces MP4 outputs at 720p and 1080p; thumbnail extraction at 1 second produces a valid JPEG.
  • CSAM sandbox test: a known-safe hash passes through; a known-flagged hash triggers the quarantine flow and sets post state to removed_csam.
  • Magic-byte mismatch (e.g., a text file with a .jpg extension) fails immediately with failed_validation state and does not retry.
  • After successful processing, the Content Service receives the state callback, post transitions to published, and CDN URLs are populated in media_urls.
  • Integration test: full job lifecycle from enqueue through publish confirmed with testcontainers.

Phase 6: Fanout Worker and Counter Sync Worker

This phase delivers the two background workers that make the feed and engagement counts work at scale: the Fanout Worker distributes new posts to follower feed queues in Redis, and the Counter Sync Worker keeps PostgreSQL counts consistent with Redis.

Contracts

Module: workers/fanout/worker

NewWorker(cfg FanoutWorkerConfig) *FanoutWorker
FanoutWorkerConfig: {
  Pool          *pgxpool.Pool
  RiverClient   *river.Client[pgx.Tx]
  RedisClient   *redis.Client
  Logger        *slog.Logger
}

Start(ctx context.Context, worker *FanoutWorker) error
  Registers the post_published job handler with 5 concurrent goroutines.
  Blocks until ctx is cancelled.

Module: workers/fanout/fanout

HandlePostPublished(ctx context.Context, cfg FanoutConfig, payload jobs.PostPublishedPayload) error
  Executes the fanout pipeline:

  1. FetchFollowers(ctx, pool, authorID string, batchSize int) — paginated follower fetch.
     Returns a channel that yields []string (user ID batches) until exhausted or ctx cancelled.
     Batch size: 500. Uses keyset pagination on (follower_id, created_at).

  2. For each follower batch:
     a. For each follower ID, calls social/cache.IsActiveUser(ctx, rdb, followerID).
     b. If active: calls PushToFeedQueue(ctx, rdb, followerID, postID, score).
     c. Inactive followers are skipped (they receive fanout-on-read).

  3. Celebrity cap: if total follower count > 50,000, fanout stops after 50,000 most recently active followers.
     The recency order is determined by the active-users sorted set score (higher score = more recently active).

  4. After fanout completes (or cap reached): enqueues a notify_followers River job.

  5. Returns nil on success. Returns error if PostgreSQL is unreachable (River will retry the job).

PushToFeedQueue(ctx context.Context, rdb *redis.Client, userID, postID string, score float64) error
  Adds postID to the user's feed sorted set (RedisKeyFeedQueue(userID)) with the given score.
  Trims the set to the last 1,000 entries (by lowest score) after insertion.
  Sets a 7-day TTL on the key on each write (resetting the inactivity window).
  Uses a Redis pipeline for ZADD + ZREMRANGEBYRANK + EXPIRE — single round-trip.
  Error: FeedQueueError wrapping Redis cause.

ComputeScore(postID string, publishedAt time.Time, engagementVelocity float64, proximityBoost bool, preferenceBoost float64) float64
  Computes the algorithmic rank score for a post:
  - Base: exponential decay from publishedAt, half-life of 6 hours. Formula: exp(-ln(2) * hoursElapsed / 6)
  - Multiplied by (1 + engagementVelocity) where velocity is the normalized Redis counter value.
  - If proximityBoost: multiply by 1.5.
  - Multiply by preferenceBoost (1.0 or 1.2 for content type match).
  Returns a float64 score. Pure function with no I/O.

Module: workers/countersync/worker

NewWorker(cfg CounterSyncConfig) *CounterSyncWorker
CounterSyncConfig: {
  Pool        *pgxpool.Pool
  RedisClient *redis.Client
  Logger      *slog.Logger
  Interval    time.Duration  // 30 seconds
}

Start(ctx context.Context, worker *CounterSyncWorker) error
  Runs the sync loop on the configured interval.
  Each iteration calls RunSyncCycle(ctx).
  Blocks until ctx is cancelled. Single instance, no concurrency.

RunSyncCycle(ctx context.Context, pool *pgxpool.Pool, rdb *redis.Client, logger *slog.Logger) error
  1. Reads all members of RedisKeyCounterDirty() (the dirty set of post IDs).
  2. For each dirty post ID:
     a. GET like count from RedisKeyCounterLikes(postID) and comment count from RedisKeyCounterComments(postID).
     b. Upserts into posts table: UPDATE posts SET like_count = $1, comment_count = $2 WHERE id = $3.
     c. Removes postID from the dirty set on success.
  3. Processes dirty set in batches of 100 to avoid holding large result sets in memory.
  4. Calls UpdateEngagementVelocity(ctx, pool, rdb) to refresh velocity signals.
  5. Calls UpdateTrendingReels(ctx, rdb) to refresh the trending Reels sorted set.
  Returns the first error encountered; partial flushes are acceptable (skipped posts remain in dirty set and are retried next cycle).

UpdateEngagementVelocity(ctx, pool *pgxpool.Pool, rdb *redis.Client) error
  Fetches the 10,000 post IDs with the highest combined like+comment count from PostgreSQL (ORDER BY like_count + comment_count DESC LIMIT 10000).
  For each, computes velocity = (current_like_count + current_comment_count) - (stored baseline).
  The baseline is the count stored in Redis at the previous cycle (RedisKeyEngagementVelocity(postID) holds the previous total; velocity = delta / interval).
  Writes the new velocity value to RedisKeyEngagementVelocity(postID).
  Updates the stored baseline.

UpdateTrendingReels(ctx, rdb *redis.Client) error
  Reads like/comment counts for all post IDs that are reels (is_reel=true) and have had counter activity in the current hour.
  Computes a trending score = engagement velocity × recency decay (same formula as ComputeScore but without proximity/preference boosts).
  Replaces RedisKeyTrendingReels() with a new sorted set of the top 500 reel post IDs by trending score.
  Uses ZADD with XX + GT flags to avoid demoting posts that are still gaining velocity.

Deliverables:

  • Fanout Worker starts, picks up post_published jobs, and populates follower feed queues in Redis within 2 seconds of job enqueue for a test account with 100 followers.
  • Celebrity cap correctly stops fanout at 50,000 followers in a load test scenario (simulated with a large follower list in the test database).
  • Feed sorted set is capped at 1,000 entries after insertion; the lowest-scored entry is removed when the cap is exceeded.
  • Counter Sync Worker flushes Redis counter values to PostgreSQL every 30 seconds; after a flush, posts.like_count matches the Redis counter for a test post.
  • Trending Reels sorted set is populated after a sync cycle with video posts that have counter activity.
  • Unit tests cover ComputeScore with all weight combinations.

Phase 7: Feed Service

This phase delivers the home feed, Reels discovery feed, and explore/hashtag feed. It is the most algorithmically complex service and depends on the Redis structures populated in Phase 6.

Contracts

Module: services/feed/handler

GET /v1/feed/home
  Authenticated. Query params: cursor (string | null), limit (int, max 30, default 20).
  Calls feed/assembly.GetHomeFeed(ctx, cfg, userID, cursor, limit) → FeedPage, error.
  Returns 200: { posts: []PostResponse, next_cursor: string | null }.
  If Redis is unreachable: falls back to fanout-on-read from PostgreSQL via assembly.FanoutOnRead.
  If PostgreSQL also unreachable: returns 503 SERVICE_UNAVAILABLE with Retry-After: 5.

GET /v1/feed/reels
  Authenticated. Query params: cursor, limit (max 20, default 10).
  Calls feed/assembly.GetReelsFeed(ctx, cfg, userID, cursor, limit) → FeedPage, error.
  Returns 200: { posts: []PostResponse, next_cursor: string | null }.

GET /v1/feed/hashtag/:tag
  Authenticated. Query params: cursor, limit (max 30, default 20).
  Calls feed/assembly.GetHashtagFeed(ctx, cfg, tag, userID, cursor, limit) → FeedPage, error.
  Returns 200: { posts: []PostResponse, next_cursor: string | null }.

Module: services/feed/assembly

type FeedConfig struct {
  Pool        *pgxpool.Pool
  RedisClient *redis.Client
  // ContentServiceURL is used for post hydration calls
  ContentServiceURL string
  Logger      *slog.Logger
}

type FeedPage struct {
  Posts      []models.Post
  NextCursor *string // nil if no more pages; opaque string (base64 of last post's score)
}

GetHomeFeed(ctx context.Context, cfg FeedConfig, userID string, cursor *string, limit int) (FeedPage, error)
  1. Checks if userID is in the active-users set (IsActiveUser).
  2a. If active: reads from RedisKeyFeedQueue(userID) using ZREVRANGEBYSCORE with cursor as score upper bound.
      Fetches post IDs, then hydrates via HydratePosts.
  2b. If inactive or feed queue is empty: calls FanoutOnRead(ctx, cfg, userID, limit).
  3. Returns FeedPage with a next_cursor derived from the score of the last returned post.

FanoutOnRead(ctx context.Context, cfg FeedConfig, userID string, limit int) (FeedPage, error)
  1. Queries PostgreSQL for all accepted following relationships of userID.
  2. For each followed user, fetches their most recent published posts from the last 7 days (up to 20 per user, via content/store pattern — direct DB query, not HTTP call for performance).
  3. Fetches scoring signals from Redis for each candidate post:
     - engagement velocity from RedisKeyEngagementVelocity(postID)
     - relationship proximity: checks RedisKeyRelationshipProximity(userID) for post author
     - content preference: reads RedisKeyContentPreference(userID)
  4. Scores all candidate posts using fanout.ComputeScore (same function defined in Phase 6).
  5. Sorts by score descending, takes top `limit` posts.
  6. Writes the full scored set (up to 1,000 posts) back to RedisKeyFeedQueue(userID) for subsequent requests in this session (using PushToFeedQueue batch variant).
  7. Touches the active-user set for this user.
  8. Returns FeedPage.

GetReelsFeed(ctx context.Context, cfg FeedConfig, userID string, cursor *string, limit int) (FeedPage, error)
  1. Reads candidates from RedisKeyTrendingReels() using ZREVRANGEBYSCORE with cursor as bound.
  2. Fetches scoring signals for personalization (proximity, preference) from Redis.
  3. Re-scores candidates using ComputeScore with the user's signals applied.
  4. Returns top `limit` candidates as FeedPage.
  (No per-user pre-computation — assembled on-read for all users.)

GetHashtagFeed(ctx context.Context, cfg FeedConfig, tag string, userID string, cursor *string, limit int) (FeedPage, error)
  1. Queries PostgreSQL for published post IDs associated with the hashtag (via post_hashtags join), ordered by published_at DESC, cursor-paginated.
  2. Hydrates and returns as FeedPage.
  No algorithmic scoring — chronological only for hashtag feed.

HydratePosts(ctx context.Context, cfg FeedConfig, postIDs []string, requestingUserID string) ([]models.Post, error)
  Fetches post metadata from PostgreSQL for the given IDs.
  Fetches live like counts from Redis (GetLikeCount per post); falls back to PostgreSQL like_count if Redis miss.
  Checks has_liked for the requesting user (HasLiked per post, batched).
  Prefixes all media URLs with CDN_BASE_URL.
  Filters out posts where author is private and requester does not follow (uses follow cache).
  Returns hydrated Post slice in the same order as postIDs.

Module: services/feed/store

ListFollowingIDs(ctx, pool, userID string) ([]string, error)
  Returns all accepted followee IDs for userID. No pagination — used internally for fanout-on-read.

ListRecentPostsByUsers(ctx, pool, userIDs []string, since time.Time, limitPerUser int) ([]models.Post, error)
  Returns published posts by the given users created after `since`, up to limitPerUser per user.
  Uses a single query with ANY($1::uuid[]) to avoid N+1.

ListPostsByHashtag(ctx, pool, tag string, cursor time.Time, limit int) ([]models.Post, error)
  Joins post_hashtags → hashtags WHERE tag = $1 AND posts.state = 'published' ORDER BY published_at DESC.

Deliverables:

  • Home feed returns ranked posts for an active user from their Redis feed queue.
  • Home feed for an inactive user triggers fanout-on-read, populates the Redis feed queue, and returns the first page — subsequent requests for the same user read from Redis.
  • Reels feed returns personalized results from the trending Reels sorted set.
  • Hashtag feed returns posts chronologically for a given tag.
  • Redis fallback is tested: when Redis is mocked to be unavailable, the feed falls back to PostgreSQL fanout-on-read.
  • All media URLs in responses are CDN-prefixed.
  • Integration test: a test user with 10 followed accounts and 50 posts across them receives a correctly ordered home feed.

Phase 8: Notification Service

This phase delivers push notifications via FCM and in-app notification reads. It is the last River job consumer.

Contracts

Module: services/notification/worker

NewWorker(cfg NotificationWorkerConfig) *NotificationWorker
NotificationWorkerConfig: {
  Pool         *pgxpool.Pool
  RiverClient  *river.Client[pgx.Tx]
  RedisClient  *redis.Client
  FCMProjectID string
  FCMSASecret  string  // secret name; loaded via secrets.Load at startup
  Logger       *slog.Logger
}

Start(ctx context.Context, worker *NotificationWorker) error
  Registers handlers for: notify_followers, notify_like, notify_comment, notify_mention.
  Starts River worker loop with 5 concurrent goroutines.
  Blocks until ctx is cancelled.

Module: services/notification/fcm

SendPush(ctx context.Context, client FCMClient, deviceToken, title, body string, data map[string]string) error
  Calls the FCM HTTP v1 API to send a push notification to a single device token.
  Retries up to 3 times with increasing delay on transient failure.
  If FCM returns a token-invalid response: calls notification/store.DeleteDeviceToken(ctx, pool, deviceToken).
  If all retries fail: logs the failure and returns nil (push is best-effort; does not propagate error to River).

type FCMClient interface {
  Send(ctx context.Context, token, title, body string, data map[string]string) (invalidToken bool, error)
}
  The FCMClient interface wraps the FCM HTTP v1 API call. The coding agent implements the concrete type using the Firebase Admin Go SDK.

Module: services/notification/store

Same error handling contract as Phase 3's services/social/store.

CreateNotification(ctx, pool, n models.Notification) error
CreateDeviceToken(ctx, pool, userID, token, platform string) error
DeleteDeviceToken(ctx, pool, token string) error
GetDeviceTokensByUser(ctx, pool, userID string) ([]models.DeviceToken, error)
ListNotifications(ctx, pool, userID string, cursor time.Time, limit int) ([]models.Notification, error)
MarkNotificationsRead(ctx, pool, userID string, notificationIDs []string) error

HasRecentLikeNotification(ctx, pool, postID, recipientID string, within time.Duration) (bool, error)
  Returns true if a "like" notification for this post was sent to recipientID within the given window (10 minutes).
  Used for like notification deduplication.

Job handlers

HandleNotifyFollowers(ctx, payload jobs.NotifyFollowersPayload) error
  Fetches the post author's follower IDs (up to 500, most recently active first).
  For each follower: calls CreateNotification (in-app) and SendPush.
  This is a best-effort notification, not a full fanout — limited to 500 followers to keep notification latency low.

HandleNotifyLike(ctx, payload jobs.NotifyLikePayload) error
  Calls HasRecentLikeNotification. If true: skips (deduplication).
  If false: CreateNotification + SendPush to post author.

HandleNotifyComment(ctx, payload jobs.NotifyCommentPayload) error
  Creates in-app notification for post author.
  If comment body mentions other users (@ parsing): enqueues notify_mention jobs for each.
  Sends push to post author.

HandleNotifyMention(ctx, payload jobs.NotifyMentionPayload) error
  Creates in-app notification + SendPush for the mentioned user.

Module: services/notification/handler

HTTP endpoints for the mobile app to read in-app notifications:

GET /v1/notifications
  Authenticated. Query: cursor, limit (max 50, default 20).
  Returns 200: { notifications: []NotificationResponse, next_cursor: string | null }.

POST /v1/notifications/read
  Authenticated. Body: { notification_ids: []string }.
  Calls MarkNotificationsRead.
  Returns 204.

POST /v1/devices/tokens
  Authenticated. Body: { token: string, platform: "ios" | "android" }.
  Upserts device token.
  Returns 201.

DELETE /v1/devices/tokens/:token
  Authenticated.
  Returns 204.

Response shape: NotificationResponse

type NotificationResponse struct {
  ID        string       `json:"id"`
  Type      string       `json:"type"`
  Actor     *UserResponse `json:"actor,omitempty"`
  PostID    *string      `json:"post_id,omitempty"`
  Read      bool         `json:"read"`
  CreatedAt string       `json:"created_at"` // RFC3339
}

Deliverables:

  • Push notification delivered to a real device (iOS or Android) when a like event is processed.
  • Like deduplication correctly suppresses the second push notification if sent within 10 minutes.
  • Stale device token (returned as invalid by FCM) is removed from PostgreSQL.
  • In-app notifications are written synchronously and appear in the /v1/notifications endpoint response.
  • Device token registration and deletion work correctly.
  • Integration test: River picks up a notify_like job and the in-app notification row is created in PostgreSQL.

Phase 9: Kong API Gateway Configuration

This phase wires Kong to the running backend services — JWT validation, rate limiting, routing, and security headers. No new application code is written; this phase produces Helm chart values and Kong declarative configuration.

Contracts

Module: infra/kong/kong.yaml (KongIngress declarative config)

Services and routes:

Each backend service is declared as a Kong Service pointing to its Kubernetes ClusterIP DNS name. Routes are defined by path prefix.

Services defined:
  social-service    → http://social-service.chroma.svc.cluster.local:8080
  content-service   → http://content-service.chroma.svc.cluster.local:8080
  feed-service      → http://feed-service.chroma.svc.cluster.local:8080
  notification-service → http://notification-service.chroma.svc.cluster.local:8080

Routes and their upstream service:
  /v1/auth/*          → social-service   (unauthenticated)
  /v1/users/*         → social-service   (authenticated, except /v1/auth/*)
  /v1/posts/*         → content-service  (authenticated)
  /v1/feed/*          → feed-service     (authenticated)
  /v1/notifications/* → notification-service (authenticated)
  /v1/devices/*       → notification-service (authenticated)

Plugins applied globally:

  • jwt plugin: validates RS256 tokens on all routes except /v1/auth/register, /v1/auth/login. Public key loaded from a Kong-managed credential pointing to the RS256 public key PEM.
  • rate-limiting-advanced plugin (Redis-backed):
    • Standard read routes: 300 requests/minute per authenticated user, 60/minute per IP (unauthenticated).
    • Write routes (POST /v1/posts/*, POST /v1/posts/*/likes, POST /v1/posts/*/comments, POST /v1/users/*/follow): 30 requests/minute per user.
    • On Redis unavailability: fallback to local policy (in-memory per pod); degraded state logged.
  • response-transformer plugin: adds security headers to all responses:
    • Strict-Transport-Security: max-age=63072000; includeSubDomains
    • X-Content-Type-Options: nosniff
    • X-Frame-Options: DENY
    • Referrer-Policy: no-referrer
  • http-log plugin: sends request/response logs to Cloud Logging via the GKE stdout pipeline.
  • cors plugin: allowed origins limited to the mobile app's registered origin scheme (chroma://). No browser origins.

Internal route exclusion:

/v1/internal/* routes (e.g., PATCH /v1/internal/posts/:postID/state) are not exposed through Kong. These are accessible only within the cluster VPC via direct service DNS. Kong's route configuration simply does not include an /v1/internal/* route.

Module: infra/helm/ (Helm charts)

One chart per service. Each chart defines:

  • Deployment with the image reference, resource requests/limits, env vars from Secret Manager (via Workload Identity), liveness and readiness probes.
  • Service (ClusterIP) for internal routing.
  • HorizontalPodAutoscaler with the min/max replicas and scaling trigger from the ADD's deployment table.
  • PodDisruptionBudget with minAvailable: 1.
  • ServiceAccount with Workload Identity annotation for the service's GCP service account.

Liveness and readiness probe endpoint (all services expose): GET /healthz returning 200 OK with body {"status":"ok"}. Each service implements this endpoint in its HTTP handler.

Deliverables:

  • Kong starts cleanly in the cluster and routes requests to the correct backend service for each path prefix.
  • A request with a valid JWT to an authenticated endpoint is routed successfully; a request with no JWT returns 401.
  • A request with an expired JWT returns 401 (not forwarded to the backend).
  • Rate limiting triggers correctly on the write endpoint after 30 requests/minute from the same user.
  • Security headers are present on all responses.
  • The /v1/internal/* path is unreachable from outside the cluster (confirmed by attempting access through the public load balancer).
  • helm upgrade --install runs without errors against the staging cluster.

Phase 10: GDPR Pipeline and Data Deletion

This phase delivers the scheduled deletion pipeline, the data export (right to portability), and the cleanup job for failed uploads. It also handles account anonymization in API responses.

Contracts

Module: workers/gdpr/worker

NewWorker(cfg GDPRWorkerConfig) *GDPRWorker
GDPRWorkerConfig: {
  Pool        *pgxpool.Pool
  GCSClient   *storage.Client
  DeliveryBucket string
  QuarantineBucket string
  Logger      *slog.Logger
}

Start(ctx context.Context, worker *GDPRWorker) error
  Runs two scheduled loops:
  - Daily at 02:00 UTC: calls RunDeletionCycle(ctx).
  - Hourly: calls RunStalePostCleanup(ctx) (wraps content/store.MarkStalePendingPostsFailed).
  Blocks until ctx is cancelled.

Module: workers/gdpr/deletion

RunDeletionCycle(ctx context.Context, pool *pgxpool.Pool, gcsClient *storage.Client, cfg DeletionConfig) error
  Fetches all rows from gdpr_deletion_requests WHERE processed_at IS NULL AND requested_at < NOW() - INTERVAL '30 days'.
  For each request, calls ProcessUserDeletion(ctx, pool, gcsClient, cfg, request).
  Records processed_at on success.
  Errors on individual users are logged and skipped — the cycle continues.

ProcessUserDeletion(ctx, pool, gcsClient, cfg, request GDPRDeletionRequest) error
  Idempotent. For the given user:
  1. Overwrites PII columns in users table: username → "deleted_{userID[:8]}", phone_hash → "", bio → NULL, profile_pic_url → NULL, display_name → "Deleted User". Sets is_deleted = true.
  2. If request.delete_posts = true: transitions all user's posts to "deleted" state, deletes GCS delivery bucket objects for those posts (using the media_urls JSONB to enumerate object keys).
     If request.delete_posts = false: sets author_id on remaining posts to a system "anonymous" user account (UUID defined as a constant: the anonymous account is created in the migration as a seed row).
  3. Deletes all rows from likes WHERE user_id = userID.
  4. Deletes all rows from comments WHERE author_id = userID.
  5. Deletes all rows from follows WHERE follower_id = userID OR followee_id = userID.
  6. Deletes all device_tokens for userID.
  7. Deletes notifications WHERE recipient_id = userID OR actor_id = userID.
  8. Each step is skipped silently if the target rows are already gone (idempotent).

RunDataExport(ctx context.Context, pool *pgxpool.Pool, gcsClient *storage.Client, quarantineBucket, userID string) (downloadURL string, error)
  Assembles a JSON archive of the user's profile, posts (with metadata, not media binaries), comments, and follow graph.
  Writes to a temporary private GCS object in the quarantine bucket at path "exports/{userID}/{timestamp}.json".
  Generates a V4 signed download URL with 30-minute expiry.
  Returns the signed URL.
  (Sending the email with the link is outside this function — the caller handles notification.)

API endpoint for export (added to Social Service handler):

POST /v1/users/me/export
  Authenticated.
  Enqueues a River job of type "gdpr_export" with payload { user_id }.
  The GDPR worker consumes this job, calls RunDataExport, and sends an in-app notification with the download URL.
  Returns 202: { message: "Export will be ready shortly." }.

API endpoint for deletion request (added to Social Service handler):

DELETE /v1/users/me
  Authenticated. Body: { delete_posts: bool }.
  Sets gdpr_deletion_requested_at = now() on the user record.
  Writes a row to gdpr_deletion_requests.
  Revokes all refresh tokens for the user.
  Returns 202: { message: "Account deletion scheduled. Your data will be removed within 30 days." }.

Anonymization in API responses

All services that return UserResponse check is_deleted on the user model before populating the response. If is_deleted = true, the response uses:

  • username: "deleted_user"
  • display_name: "Deleted User"
  • profile_pic_url: null
  • bio: null

This check is implemented once in a ToUserResponse(u models.User) UserResponse helper in internal/models/response.go and used everywhere — never inline.

Deliverables:

  • Running the deletion pipeline against a synthetic test user results in all PII columns overwritten, all social graph rows deleted, and GCS delivery objects removed for deleted posts.
  • The pipeline is idempotent: running it twice on the same user produces no errors and no additional changes.
  • Data export generates a valid JSON file in GCS and returns a signed download URL that expires after 30 minutes.
  • Anonymous user responses show "deleted_user" for all endpoints that return deleted users' data.
  • Integration test: full deletion cycle against a test user in testcontainers environment passes all assertions.

Phase 11: CI/CD Pipeline and Deployment

This phase delivers the GitHub Actions pipeline, Helm chart finalization, and the dual-region production deployment. It is the last phase — every service is running and tested before this phase begins.

Contracts

Module: .github/workflows/ci.yml

Triggered on push to any branch and on pull request to main.

Steps in order:

  1. Check out code.
  2. Set up Go 1.23.
  3. Run go vet ./....
  4. Start testcontainers environment (PostgreSQL 16, Redis 7, MinIO).
  5. Run migrations against the test PostgreSQL instance.
  6. Run go test ./... -race -count=1 with the testcontainers environment variables injected.
  7. Run PhotoDNA integration test against the sandbox endpoint (uses PHOTODNA_API_URL pointing to sandbox, PHOTODNA_API_KEY_SECRET from GitHub Actions secrets).
  8. Build Docker images for all services using multi-stage builds.
  9. Verify each image starts and passes its /healthz probe (using docker run --rm with a brief startup wait).

Module: .github/workflows/deploy-staging.yml

Triggered on push to main (after CI passes).

Steps:

  1. Authenticate to GCP via Workload Identity Federation (no static key).
  2. Build and push Docker images to Artifact Registry with tag git-{short SHA}.
  3. Run helm upgrade --install for each service chart against the staging GKE cluster (us-central1 only), passing the new image tag.
  4. Run kubectl rollout status deployment/{service-name} -n chroma --timeout=300s for each service.
  5. If any rollout status check fails: run kubectl rollout undo deployment/{service-name} -n chroma for the failing service and exit with failure.

Module: .github/workflows/deploy-production.yml

Triggered manually via workflow_dispatch, or automatically after a GitHub Actions environment: production approval gate.

Steps:

  1. Same GCP authentication as staging.
  2. Push the same image tags already in Artifact Registry (no rebuild).
  3. Deploy to us-central1 cluster first. Run rollout status checks.
  4. If us-central1 succeeds: deploy to europe-west1 cluster. Run rollout status checks.
  5. If either cluster fails: auto-rollback the failing cluster and notify via GitHub Actions job summary.

Module: infra/helm/values-staging.yaml and infra/helm/values-production.yaml

Staging values: all services at minimum replica counts (1 replica each for workers, 2 for user-facing services). Resource requests reduced by 50%.

Production values: replica counts and resource requests per the ADD's deployment table. HPA configured with the correct scaling triggers per service.

Docker image specification

All service images use a two-stage Dockerfile:

  • Stage 1: golang:1.23-alpine — builds the Go binary with CGO_ENABLED=0.
  • Stage 2: gcr.io/distroless/static-debian12 — copies only the binary and any required static assets.
  • The Media Processing Worker image uses golang:1.23-alpine for the final stage (not distroless) because FFmpeg must be installed via apk. FFmpeg 6.1 is installed in the final stage.
  • All images run as a non-root user (USER 65534).

Deliverables:

  • CI pipeline runs and passes on every push; a failing test correctly blocks the pipeline.
  • Staging deployment completes successfully; all services pass /healthz after deployment.
  • A simulated rollout failure (deliberately bad image) triggers auto-rollback and the pipeline exits with failure.
  • Production deployment completes in both regions; the europe-west1 deployment only proceeds after us-central1 is confirmed healthy.
  • All Helm charts pass helm lint with no warnings.
  • Docker images for all services are present in Artifact Registry with the correct tags.

Tickets

Tickets Document

Here is your step-by-step ticket backlog to build Chroma. Whether you're pasting these sequentially into Claude Code or importing them into Linear for your team, these tickets provide the exact implementation steps to bring this architecture to life.

Phase 1: Data Layer and Schema

Ticket 1.1: Create User and Social Graph SQL Schema

Objective: This ticket establishes the core users and follows tables in PostgreSQL, which are foundational for all other data entities.

Context: The users table is the primary entity store for user accounts, as defined in the Core Data Entities section of the Architecture Design Document. The follows table represents the social graph, a critical component for the Social Service and the feed fanout logic. This initial schema work is essential before any application logic can be built.

Technical Directives:

  • Create an SQL migration file named 0001_users.sql.
  • Define the users table with the specified columns: id, username, phone_hash, profile_pic_url, bio, display_name, is_private, is_deleted, gdpr_deletion_requested_at, and created_at.
  • Ensure column types, constraints (UNIQUE, NOT NULL), and default values match the Implementation Plan exactly.
  • Implement a case-insensitive uniqueness constraint on the username column using a lowercased index.
  • Create a GIN index on a tsvector column combining username and display_name to support full-text search.
  • Create a separate SQL migration file named 0003_follows.sql.
  • Define the follows table with a composite primary key on follower_id and followee_id, and columns state and created_at.
  • Create indexes on follower_id, followee_id, and created_at DESC as specified.
  • Foreign keys must reference the users.id column.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • The 0001_users.sql migration runs successfully against a clean PostgreSQL 16 database.
  • The users table is created with all specified columns, data types, and constraints.
  • A user can be inserted with a unique username, and an attempt to insert a second user with the same username (different case) fails due to the unique index.
  • The 0003_follows.sql migration runs successfully after the users migration.
  • The follows table is created with the correct composite primary key and indexes.

Dependencies: None


Ticket 1.2: Create Content SQL Schema

Objective: This ticket creates the posts table schema for storing media metadata and adds the media_urls column to support processed media variants.

Context: The posts table is the central entity for the Content Service, tracking all user-generated content from initial upload to final publication. The state machine defined in this table's state column is critical for the media processing pipeline, as described in the Component Architecture section of the ADD.

Technical Directives:

  • Create an SQL migration file named 0002_posts.sql.
  • Define the posts table with the specified columns: id, author_id, media_type, state, caption, location, like_count, comment_count, is_reel, created_at, and published_at.
  • The author_id column must be a foreign key referencing users.id.
  • The media_type ENUM must contain 'photo' and 'video'.
  • The state ENUM must contain 'pending', 'processing', 'published', 'removed_csam', 'removed_moderation', 'deleted', 'failed_upload', and 'failed_validation'.
  • Create indexes on author_id, created_at DESC, and state (for published posts).
  • Create a separate SQL migration file named 0011_media_urls.sql.
  • This migration must alter the posts table to add a media_urls column of type JSONB, which can be nullable.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • The 0002_posts.sql migration runs successfully against the database.
  • The posts table exists with all specified columns, data types, and constraints, including the correct ENUM values.
  • The 0011_media_urls.sql migration runs successfully after the posts migration.
  • The posts table schema is successfully altered to include the media_urls JSONB column.

Dependencies: Ticket 1.1


Ticket 1.3: Create Engagement SQL Schema

Objective: This ticket creates the comments and likes tables to store user interactions with posts.

Context: The likes table is designed to handle high write concurrency by offloading real-time counting to Redis, as specified in ADR-004. The PostgreSQL table provides the source of truth and allows for point lookups. The comments table supports threaded replies and is queried directly for post views.

Technical Directives:

  • Create an SQL migration file named 0004_comments.sql.
  • Define the comments table with columns id, post_id, author_id, body, parent_id, and created_at.
  • Foreign keys for post_id and author_id must reference the posts and users tables, respectively.
  • The parent_id must be a self-referencing nullable foreign key to comments.id.
  • Create an SQL migration file named 0005_likes.sql.
  • Define the likes table with a composite primary key on post_id and user_id, and a created_at column.
  • The table must be partitioned by hash on the post_id column into exactly 8 partitions.
  • Create an index on user_id to facilitate efficient data deletion for GDPR compliance.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • The 0004_comments.sql migration runs successfully.
  • The comments table is created with all specified columns, foreign keys, and indexes.
  • The 0005_likes.sql migration runs successfully.
  • The likes table is created with the correct composite primary key.
  • Querying the database confirms that 8 partitions have been created for the likes table.
  • The index on user_id in the likes table is present.

Dependencies: Ticket 1.1, Ticket 1.2


Ticket 1.4: Create Hashtag and Tagging SQL Schema

Objective: This ticket creates the necessary tables for hashtag storage and the association between posts and hashtags.

Context: As detailed in the Data Architecture section, hashtags are stored canonically in PostgreSQL to support search and GDPR-compliant deletion, while trending counts are managed in Redis. This schema provides the relational backbone for hashtag functionality.

Technical Directives:

  • Create an SQL migration file named 0006_hashtags.sql.
  • Define a hashtags table with columns id, tag (unique), and created_at.
  • Define a post_hashtags junction table with columns post_id and hashtag_id.
  • The junction table must have a composite primary key on post_id and hashtag_id.
  • Foreign keys in the junction table must reference the posts and hashtags tables.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • The 0006_hashtags.sql migration runs successfully.
  • The hashtags table is created with a unique constraint on the tag column.
  • The post_hashtags junction table is created with the correct composite primary key and foreign key constraints.

Dependencies: Ticket 1.2


Ticket 1.5: Create Notification and Device Management SQL Schema

Objective: This ticket establishes the schema for storing in-app notifications and user device tokens for push notifications.

Context: The Notification Service, as described in the Component Architecture, relies on these tables. The notifications table stores a persistent history of user notifications, while the device_tokens table is essential for targeting pushes via Firebase Cloud Messaging (FCM).

Technical Directives:

  • Create an SQL migration file named 0007_notifications.sql.
  • Define the notifications table with columns id, recipient_id, type, actor_id, post_id, read, and created_at.
  • The type ENUM must contain 'like', 'comment', 'follow', and 'mention'.
  • Define foreign keys for recipient_id, actor_id, and post_id.
  • Create an index on (recipient_id, created_at DESC).
  • Create an SQL migration file named 0008_device_tokens.sql.
  • Define the device_tokens table with columns id, user_id, token, platform, created_at, and updated_at.
  • The platform ENUM must contain 'ios' and 'android'.
  • The token column must have a unique index.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • The 0007_notifications.sql migration runs successfully.
  • The notifications table is created with the correct columns, ENUM values, and index.
  • The 0008_device_tokens.sql migration runs successfully.
  • The device_tokens table is created with the correct columns, ENUM values, and a unique index on the token.

Dependencies: Ticket 1.1, Ticket 1.2


Ticket 1.6: Create GDPR Compliance SQL Schema

Objective: This ticket creates the table to track user account deletion requests, a core part of the GDPR compliance pipeline.

Context: The GDPR deletion pipeline, as described in the Data Lifecycle section of the ADD, is triggered by entries in this table. It ensures that user deletion requests are formally logged and processed in a timely and auditable manner.

Technical Directives:

  • Create an SQL migration file named 0009_gdpr_deletion_requests.sql.
  • Define the gdpr_deletion_requests table with columns user_id (primary key), requested_at, delete_posts, and processed_at.
  • The user_id must be a foreign key referencing the users table.
  • The processed_at column must be nullable.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • The 0009_gdpr_deletion_requests.sql migration runs successfully.
  • The gdpr_deletion_requests table is created with the specified columns, primary key, and foreign key.

Dependencies: Ticket 1.1


Ticket 1.7: Initialize River Job Queue Schema

Objective: This ticket creates the necessary tables and schema for the River job queue within the PostgreSQL database.

Context: The architecture (ADR-002) uses River, a PostgreSQL-backed job queue, to avoid the operational overhead of a separate message broker. This ticket sets up the schema River requires, enabling subsequent worker phases to enqueue and consume jobs.

Technical Directives:

  • Create an SQL migration file named 0010_river.sql.
  • The implementation must use the River Go client's built-in schema migration functionality.
  • The migration should create all required River tables (e.g., river_jobs) within a dedicated river schema.
  • This setup should be part of the overall database migration runner.

Scope Boundaries:

  • PostgreSQL database schema
  • SQL migration files within internal/db/schema

Acceptance Criteria:

  • Running the 0010_river.sql migration (or the Go function it calls) successfully creates the river schema.
  • The river_jobs table and other related River tables exist within the river schema after the migration completes.

Dependencies: None


Ticket 1.8: Define Redis Key Convention Module

Objective: This ticket creates a Go module that defines all Redis key naming conventions as constants and functions.

Context: Standardizing Redis key names in a central module prevents inconsistencies and bugs across different services that interact with Redis. This module will be a shared dependency for the Feed Service, Social Service, Counter Sync Worker, and API Gateway, as outlined in the Data Architecture.

Technical Directives:

  • Create a Go package named redis within the internal/db module.
  • Implement exported functions or constants for every key schema defined in the Implementation Plan.
  • Functions that require an ID (e.g., userID, postID) must accept it as an argument and return the fully formatted key string.
  • The module must be self-contained and have no dependencies on other application packages.
  • Document the Redis data type and any TTL or capping policies associated with each key in the code comments.

Scope Boundaries:

  • Go module internal/db/redis

Acceptance Criteria:

  • The internal/db/redis module compiles successfully.
  • All specified key-generating functions and constants are exported from the package.
  • The output of each function matches the documented key format exactly (e.g., RedisKeyFeedQueue("user123") returns "feed:v1:user123").
  • This module is ready to be consumed by the shared libraries in the Following Phase.

Dependencies: None


Ticket 1.9: Create Local Development Environment with Docker Compose

Objective: This ticket creates a docker-compose.yml file to run a complete local development stack, including automated database migrations.

Context: A reproducible local development environment is critical for developer productivity and consistency. As per the Implementation Plan, this setup allows any developer to start the required backing services (PostgreSQL, Redis, MinIO) with a single command and have a fully initialized database ready for use.

Technical Directives:

  • Create a docker-compose.yml file in the project root.
  • Define services for PostgreSQL 16, Redis 7, and MinIO (as a GCS-compatible object store).
  • Configure the PostgreSQL service to automatically run all SQL migrations created in the preceding tickets upon startup. This can be achieved using an init container or by mounting the migration files and using the official PostgreSQL image's init-db script mechanism.
  • Ensure all services are on a shared Docker network.
  • Expose the default ports for each service to the host machine.

Scope Boundaries:

  • Docker Compose configuration

Acceptance Criteria:

  • Running docker-compose up from the project root successfully starts containers for PostgreSQL, Redis, and MinIO without errors.
  • After startup, connecting to the local PostgreSQL instance reveals that all tables, indexes, partitions, and the River schema from tickets 1-7 have been created.
  • The local Redis and MinIO instances are accessible on their default ports.

Dependencies: Ticket 1.1, Ticket 1.2, Ticket 1.3, Ticket 1.4, Ticket 1.5, Ticket 1.6, Ticket 1.7

Phase 2: Shared Infrastructure Libraries

Ticket 2.1: Structured Logger Implementation

Objective: Create a reusable, structured JSON logger that all backend services will use for logging.

Context: This foundational library ensures consistent, machine-readable logging across all services, which is critical for debugging and monitoring in a distributed system. As specified in the Architecture Design Document, all logs are written to standard output to be ingested by Cloud Logging on GKE.

Technical Directives:

  • Implement the New function within the internal/platform/logger module.
  • The function must accept a service name string and return a standard slog.Logger instance.
  • The returned logger must be configured to output logs in JSON format.
  • The provided service name must be included as a default field in every log entry produced by the logger instance.
  • Log output must be directed to standard output (stdout).

Scope Boundaries:

  • internal/platform/logger module

Acceptance Criteria:

  • Calling logger.New("test-service") returns a non-nil logger instance.
  • When the logger instance is used to log a message, the output is a single line of valid JSON.
  • The JSON output contains the log level, message, timestamp, and the service name "test-service".

Dependencies: None


Ticket 2.2: Secret Manager Client

Objective: Implement a client to securely fetch secrets from Google Secret Manager using Workload Identity.

Context: This module abstracts away the specifics of accessing secrets, providing a simple interface for other platform components like the database connector and JWT key loader. The Architecture Design Document mandates using Google Secret Manager and Workload Identity to avoid static credentials in the environment.

Technical Directives:

  • Implement the Load function and associated error types (SecretNotFoundError, SecretAccessError) in the internal/platform/secrets module.
  • The Load function must use the ambient Google Cloud credentials provided by Workload Identity; it must not require any explicit credential configuration.
  • The function must fetch the latest version of the secret specified by name.
  • If the secret name does not exist, the function must return a SecretNotFoundError.
  • For any other failure (e.g., permissions error, network issue), the function must return a SecretAccessError that wraps the underlying cause.

Scope Boundaries:

  • internal/platform/secrets module

Acceptance Criteria:

  • Calling secrets.Load with the name of an existing secret returns its string payload and a nil error.
  • Calling secrets.Load with a name that does not exist returns a SecretNotFoundError.
  • Disabling the Secret Manager API or providing invalid permissions and calling secrets.Load results in a SecretAccessError.
  • Unit tests for this module successfully mock the Secret Manager client to validate all error and success paths.

Dependencies: Ticket 2.1


Ticket 2.3: PostgreSQL Database Connector

Objective: Create a function to establish a connection pool to the Cloud SQL PostgreSQL database.

Context: This module provides a standardized way for all data-driven services to connect to the primary database. It leverages the secrets module created in the previous ticket to securely fetch the database password at runtime, as per the architecture's security requirements.

Technical Directives:

  • Implement the Connect function and DBConfig struct in the internal/platform/db module.
  • The function must use the secrets.Load function to fetch the database password from the secret named in DBConfig.PasswordSecretName.
  • It must establish a connection pool using the pgxpool library.
  • The connection pool must be configured with a minimum of 2 and a maximum of 20 connections.
  • The connection timeout must be set to 30 seconds.
  • On any failure to connect or fetch the secret, the function must return a DBConnectionError that wraps the underlying cause.

Scope Boundaries:

  • internal/platform/db module

Acceptance Criteria:

  • Calling db.Connect with valid configuration (and a running database instance via docker-compose) returns a connected *pgxpool.Pool and a nil error.
  • Calling db.Connect with an invalid password secret name results in a DBConnectionError.
  • Calling db.Connect with an incorrect hostname results in a DBConnectionError.
  • The returned connection pool can successfully execute a simple query (e.g., SELECT 1).

Dependencies: Ticket 2.2


Ticket 2.4: Redis Cache Connector

Objective: Create a function to establish a client connection to the Memorystore Redis instance.

Context: This module provides a standardized connector for services that interact with Redis for caching, session storage, and feed management. It ensures all services use a consistent client configuration.

Technical Directives:

  • Implement the Connect function and RedisConfig struct in the internal/platform/cache module.
  • The function must establish a client connection to the Redis server specified in the RedisConfig.
  • On any failure to connect, the function must return a CacheConnectionError that wraps the underlying cause.

Scope Boundaries:

  • internal/platform/cache module

Acceptance Criteria:

  • Calling cache.Connect with valid configuration for a running Redis instance returns a connected client and a nil error.
  • The returned client can successfully execute a PING command against the Redis server.
  • Calling cache.Connect with an incorrect hostname results in a CacheConnectionError.

Dependencies: Ticket 2.1


Ticket 2.5: JWT Authentication Library

Objective: Implement functions for loading JWT signing keys and for issuing and validating RS256 JSON Web Tokens.

Context: This module centralizes all JWT logic, ensuring that token issuance (by the Social Service) and validation (by the API Gateway and other services) use the same cryptographic keys and validation rules. Keys are loaded securely via the secrets module, per the ADD.

Technical Directives:

  • Implement all functions and error types specified in the internal/platform/auth module contract.
  • LoadKeyPair must use secrets.Load to fetch the PEM-encoded private and public keys from Secret Manager, using environment variables for the secret names. It must parse them into RSA key objects.
  • IssueAccessToken must generate a JWT signed with the provided private key, including the specified claims (sub, jti, exp, iat) and a 15-minute expiry.
  • IssueRefreshToken must generate a cryptographically secure random string suitable for use as a refresh token.
  • ValidateAccessToken must verify the token's signature against the public key and check for expiry. It must return the claims on success or a typed error (TokenExpiredError, TokenInvalidError) on failure.

Scope Boundaries:

  • internal/platform/auth module

Acceptance Criteria:

  • LoadKeyPair successfully fetches and parses valid PEM-encoded keys.
  • A token issued by IssueAccessToken can be successfully validated by ValidateAccessToken before it expires.
  • ValidateAccessToken returns a TokenExpiredError for a token whose expiration time is in the past.
  • ValidateAccessToken returns a TokenInvalidError for a token with a bad signature or malformed content.
  • IssueRefreshToken returns a non-empty, securely random token string and its ID.

Dependencies: Ticket 2.2


Ticket 2.6: Standard API Error Response Utilities

Objective: Create a standardized structure and helper function for writing JSON error responses from API endpoints.

Context: This module ensures that all services return errors in a consistent, predictable format, which simplifies client-side error handling. The APIError struct and standard codes defined here will be used by all HTTP handlers in subsequent phases.

Technical Directives:

  • Define the APIError struct in the internal/platform/apierror module exactly as specified in the contract.
  • Implement the WriteError helper function.
  • WriteError must accept an http.ResponseWriter, an HTTP status code, a machine-readable error code string, and a human-readable message string.
  • The function must set the Content-Type header to application/json.
  • It must write the HTTP status code to the response header.
  • It must write a JSON-encoded APIError object to the response body.

Scope Boundaries:

  • internal/platform/apierror module

Acceptance Criteria:

  • Calling apierror.WriteError with a test http.ResponseWriter writes the correct HTTP status code.
  • The response body written by the function is a valid JSON object matching the APIError structure.
  • The Content-Type header of the response is set to application/json.

Dependencies: Ticket 2.1


Ticket 2.7: Input Sanitization Utilities

Objective: Develop a set of reusable functions for validating and sanitizing user-provided text inputs.

Context: This library provides a critical security function by ensuring all user input is cleaned before being processed or stored, preventing cross-site scripting (XSS) and other injection attacks. As defined in the ADD, sanitization is enforced at the service layer.

Technical Directives:

  • Implement the Text and Username functions, along with the ValidationError type, in the internal/platform/sanitize module.
  • Text must strip all HTML tags, reject any input containing null bytes, trim leading/trailing whitespace, and enforce the specified maximum length.
  • Username must convert the input to lowercase, verify it contains only alphanumeric characters and underscores, and check that its length is between 1 and 30 characters.
  • Both functions must return a ValidationError with a descriptive reason if any validation rule is violated.

Scope Boundaries:

  • internal/platform/sanitize module

Acceptance Criteria:

  • sanitize.Text correctly removes <b> and <script> tags from an input string.
  • sanitize.Text returns a ValidationError if the input contains a null byte (\x00).
  • sanitize.Text returns a ValidationError if the input (after stripping HTML) exceeds the maxLen.
  • sanitize.Username returns a ValidationError for inputs containing spaces, hyphens, or other special characters.
  • sanitize.Username returns a ValidationError for inputs that are too long or empty.
  • sanitize.Username correctly lowercases a valid mixed-case username.

Dependencies: Ticket 2.1

Phase 3: Social Service — Registration, Auth, Profiles, and Follow Graph

Ticket 3.1: Define Shared Data Models

Objective: This ticket creates the internal/models package containing the Go structs for all core database entities used across services.

Context: As per the Architecture Design Document, all services share a common understanding of core data entities like User, Post, and Comment. This package establishes that single source of truth, preventing model drift between services and ensuring a consistent data representation layer. While the Social Service only directly uses User, Follow, and RefreshToken in this phase, defining all shared models now prevents breaking changes later.

Technical Directives:

  • Create a new Go package at internal/models.
  • Implement the Go structs for User, Follow, RefreshToken, Post, Comment, Notification, and DeviceToken exactly as defined in the "Target Phase" section's internal/models contract.
  • Use standard Go types (string, bool, int64, time.Time) and pointers for nullable fields.
  • Do not include any database-specific tags (e.g., db:"...") or business logic in these structs; they are plain data transfer objects.

Scope Boundaries:

  • internal/models package

Acceptance Criteria:

  • The internal/models package exists and compiles successfully.
  • All specified structs (User, Follow, RefreshToken, Post, Comment, Notification, DeviceToken) are present in the package with the correct field names and types.

Dependencies: None


Ticket 3.2: Implement Social Service Database Schema

Objective: This ticket creates the SQL migration scripts to define the database schema for the Social Service.

Context: The Social Service is the authority for user identity, profiles, and relationships. This ticket lays the foundational PostgreSQL schema required to store this data, as described in the Architecture Design Document's Data Architecture section. It includes tables for users, authentication tokens, and the social graph (follows and blocks).

Technical Directives:

  • Create a database migration for the following tables, based on the structs defined in the internal/models package:
    • users: Include columns for all fields in the User model. Create a UNIQUE index on the lowercased username and on phone_hash. Create a GIN index on a tsvector column combining username and display_name for full-text search.
    • refresh_tokens: Include columns for all fields in the RefreshToken model.
    • follows: Include a composite primary key on follower_id and followee_id, and an index on followee_id to support listing followers.
    • blocks: Include a composite primary key on blocker_id and blocked_id.
  • Use UUID types for all id columns.
  • Use TIMESTAMPTZ for all timestamp columns.
  • Ensure foreign key constraints are established where appropriate (e.g., from follows to users).

Scope Boundaries:

  • Database schema migrations

Acceptance Criteria:

  • A new database migration can be successfully applied to a clean PostgreSQL database.
  • The users, refresh_tokens, follows, and blocks tables are created with the correct columns, types, constraints, and indexes.
  • The GIN index required for full-text search exists on the users table.

Dependencies: Ticket 3.1


Ticket 3.3: Implement Firebase Token Verification Module

Objective: This ticket implements the VerifyFirebaseToken function to validate Firebase ID tokens and extract user phone numbers.

Context: As specified in the Architecture Design Document, user registration and login are handled via Firebase Auth's OTP flow to offload SMS delivery complexity. The Social Service must verify the short-lived token provided by the client after a successful OTP flow to trust the user's identity. This module provides that core verification logic.

Technical Directives:

  • Create the services/social/auth module.
  • Implement the VerifyFirebaseToken function according to its contract.
  • The function must fetch and cache Firebase's public keys to perform JWT validation locally.
  • Upon successful validation, the function must extract the E.164 formatted phone number from the token's claims.
  • Return a FirebaseTokenError for any validation failure, including invalid signature, expiry, or missing claims.

Scope Boundaries:

  • services/social/auth module

Acceptance Criteria:

  • Calling VerifyFirebaseToken with a valid, unexpired Firebase ID token returns the correct phone number and no error.
  • Calling the function with an invalid, expired, or malformed token returns a FirebaseTokenError.
  • The implementation successfully caches and uses Firebase's public keys for validation.

Dependencies: None


Ticket 3.4: Implement User and Refresh Token Store Functions

Objective: This ticket implements the data access functions in services/social/store for creating, retrieving, and updating users, and for managing refresh tokens.

Context: This module forms the core data access layer for user identity management. It directly interacts with the database schema created in a previous ticket and provides a clean API for the service's handlers to perform CRUD operations on user and authentication data. It utilizes the internal/platform/db package for database connectivity.

Technical Directives:

  • Create the services/social/store module.
  • Implement the functions: CreateUser, GetUserByPhoneHash, GetUserByUsername, GetUserByID, and UpdateUser.
  • Implement the functions: CreateRefreshToken, GetRefreshToken, and RevokeRefreshToken.
  • All functions must accept a *pgxpool.Pool from the internal/platform/db package.
  • All functions must use the model types from the internal/models package for parameters and return values.
  • CreateUser must hash the phone number using SHA-256 before storing it in the phone_hash column.

Scope Boundaries:

  • services/social/store module

Acceptance Criteria:

  • Calling CreateUser inserts a new row into the users table and returns the corresponding models.User struct.
  • All GetUserBy... functions correctly retrieve a user from the database or return an error if not found.
  • UpdateUser correctly modifies the specified fields for a given user ID.
  • CreateRefreshToken, GetRefreshToken, and RevokeRefreshToken correctly manage records in the refresh_tokens table.

Dependencies: Ticket 3.2


Ticket 3.5: Implement Follow and Block Store Functions

Objective: This ticket implements the data access functions in services/social/store for all follow graph and block list operations.

Context: The Social Service owns the social graph. These store functions provide the low-level database operations for creating and deleting follow relationships and block records, which are fundamental to the application's social features and safety policies.

Technical Directives:

  • Within the services/social/store module, implement the functions: CreateFollow, DeleteFollow, ApproveFollow, GetFollow, ListFollowers, and ListFollowing.
  • Implement the functions: CreateBlock, DeleteBlock, and IsBlocked.
  • ListFollowers and ListFollowing must support cursor-based pagination using the created_at timestamp.
  • The IsBlocked function should check for a block relationship in either direction between two user IDs.

Scope Boundaries:

  • services/social/store module

Acceptance Criteria:

  • CreateFollow and DeleteFollow correctly add and remove rows from the follows table.
  • ListFollowers and ListFollowing return a correctly paginated list of models.User.
  • CreateBlock and DeleteBlock correctly add and remove rows from the blocks table.
  • IsBlocked returns true if a block exists in either direction between two users, and false otherwise.

Dependencies: Ticket 3.2


Ticket 3.6: Implement User Search Store Function

Objective: This ticket implements the user search function in services/social/store using PostgreSQL's full-text search capabilities.

Context: As per ADR-005, user search at launch will be handled by PostgreSQL Full-Text Search to minimize operational overhead. This ticket implements the specific query that leverages the GIN index created in the schema ticket to provide efficient search over usernames and display names.

Technical Directives:

  • Within the services/social/store module, implement the SearchUsers function.
  • The function must use a to_tsvector and plainto_tsquery query against the indexed tsvector column in the users table.
  • The function must exclude users who have been soft-deleted (is_deleted is true).
  • The function should implement cursor-based pagination.

Scope Boundaries:

  • services/social/store module

Acceptance Criteria:

  • Calling SearchUsers with a query string returns a list of matching users from the database.
  • The search is case-insensitive and matches on partial words in both username and display name.
  • Soft-deleted users are not included in search results.

Dependencies: Ticket 3.2


Ticket 3.7: Implement Social Service Cache Functions

Objective: This ticket implements the Redis-backed caching functions in services/social/cache for active user tracking and follow relationships.

Context: The Architecture Design Document specifies a hybrid feed model that relies on knowing which users are "active". This module implements the TouchActiveUser function to update this Redis set. It also provides caching for follow relationships to reduce database load on hot query paths, such as rendering feed items. It utilizes the internal/platform/cache package for Redis connectivity.

Technical Directives:

  • Create the services/social/cache module.
  • Implement TouchActiveUser to add a user ID to a Redis sorted set with the current Unix timestamp as the score.
  • Implement IsActiveUser to check if a user exists in the sorted set with a score from the last 48 hours.
  • Implement SetFollowCache, GetFollowCache, and InvalidateFollowCache to manage a simple key-value cache in Redis for follower:followee relationships. The cache should have a 5-minute TTL.

Scope Boundaries:

  • services/social/cache module

Acceptance Criteria:

  • TouchActiveUser successfully adds or updates a user's score in the active-users Redis sorted set.
  • IsActiveUser correctly returns true for a recently touched user and false for an inactive or nonexistent user.
  • SetFollowCache and GetFollowCache correctly round-trip a boolean follow state to Redis.
  • InvalidateFollowCache successfully deletes the corresponding cache key.

Dependencies: Ticket 3.1


Ticket 3.8: Implement Registration and Login API Endpoints

Objective: This ticket implements the POST /v1/auth/register and POST /v1/auth/login HTTP handlers.

Context: These endpoints are the primary entry points for users into the application. They orchestrate the process of verifying a user's phone number via Firebase, creating a user record, and issuing the initial set of authentication tokens.

Technical Directives:

  • Implement the register and login handlers as specified in the services/social/handler contract.
  • Use the services/social/auth.VerifyFirebaseToken function to validate the incoming token.
  • Use the sanitization functions from the internal/platform/sanitize package for username and display name.
  • Use the user store functions from services/social/store to create or retrieve user records.
  • Use the IssueAccessToken and IssueRefreshToken functions from the internal/platform/auth package to generate JWTs.
  • Store the new refresh token using services/social/store.CreateRefreshToken.
  • Return the UserResponse shape along with the tokens.
  • Implement correct error handling for conflicts (username/phone taken), invalid input, and failed Firebase verification.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • A POST to /v1/auth/register with a valid Firebase token and unique username creates a user and returns a 201 status with tokens and user data.
  • A POST to /v1/auth/register with an already-used username or phone returns a 409 Conflict error.
  • A POST to /v1/auth/login with a valid Firebase token for an existing user returns a 200 status with new tokens.
  • A POST to /v1/auth/login for a non-existent user returns a 404 Not Found error.

Dependencies: Ticket 3.3, Ticket 3.4, Ticket 3.7


Ticket 3.9: Implement Token Refresh and Logout API Endpoints

Objective: This ticket implements the POST /v1/auth/refresh and POST /v1/auth/logout HTTP handlers.

Context: These endpoints manage the lifecycle of an authenticated session. The refresh endpoint allows a client to obtain a new short-lived access token using a long-lived refresh token, while the logout endpoint provides a secure way to invalidate the current session.

Technical Directives:

  • Implement the refresh and logout handlers as specified in the services/social/handler contract.
  • For refresh, use store.GetRefreshToken to look up the token and validate it. Issue a new access token using platform/auth.IssueAccessToken.
  • For logout, extract the token ID (jti claim) from the validated access token in the request context and call store.RevokeRefreshToken.
  • The logout endpoint must be behind the authentication middleware.
  • Return the appropriate HTTP status codes and error responses for invalid, expired, or revoked tokens.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • A POST to /v1/auth/refresh with a valid refresh token returns a 200 status with a new access token.
  • A POST to /v1/auth/refresh with an invalid or revoked token returns a 401 Unauthorized error.
  • An authenticated POST to /v1/auth/logout returns a 204 No Content status and marks the corresponding refresh token as revoked in the database.

Dependencies: Ticket 3.4


Ticket 3.10: Implement User Profile API Endpoints

Objective: This ticket implements the GET /v1/users/:username and PATCH /v1/users/me HTTP handlers for viewing and updating user profiles.

Context: These endpoints allow users to view public profiles and manage their own profile information (display name, bio, etc.). This is a core feature of any social application.

Technical Directives:

  • Implement the getUserProfile and updateUserProfile handlers.
  • getUserProfile must fetch user data using store.GetUserByUsername. It must enforce privacy rules: if the target user is private and the requester does not follow them, return a limited view.
  • updateUserProfile is an authenticated endpoint for the /me path. It must only allow a user to update their own profile.
  • All user-provided input in the PATCH request must be sanitized using the internal/platform/sanitize package.
  • Both endpoints should return data in the UserResponse shape.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • A GET to /v1/users/some_user returns the correct user's public profile data.
  • An authenticated PATCH to /v1/users/me with valid data updates the user's record in the database and returns the updated profile.
  • Attempting to PATCH with data that fails sanitization (e.g., bio is too long) returns a 400 Validation Error.

Dependencies: Ticket 3.4, Ticket 3.5


Ticket 3.11: Implement Follow Management API Endpoints

Objective: This ticket implements the API endpoints for a user to follow, unfollow, and approve follow requests.

Context: These authenticated endpoints manipulate the core social graph. They orchestrate calls to the database store and the Redis cache to ensure the follow state is updated correctly and consistently.

Technical Directives:

  • Implement the followUser, unfollowUser, and approveFollow handlers as specified in the services/social/handler contract.
  • All endpoints must be authenticated.
  • The followUser handler must check if the target user is private and create a pending follow record if so.
  • When following a public user, call cache.TouchActiveUser to mark the follower as active.
  • When a follow is created or deleted, call cache.InvalidateFollowCache to remove the stale cache entry.
  • The approveFollow handler must verify that the authenticated user is the one being followed (the followee).
  • Enforce block list rules: a user cannot follow someone who has blocked them, or whom they have blocked.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • An authenticated POST to /v1/users/:userID/follow creates a follow relationship. The state is 'accepted' for public users and 'pending' for private users.
  • An authenticated DELETE to /v1/users/:userID/follow removes the follow relationship and invalidates the cache.
  • An authenticated POST to /v1/users/:userID/follow/approve by the correct user changes a 'pending' follow to 'accepted'.
  • Attempting to follow a user when a block is in place returns a 403 Forbidden error.

Dependencies: Ticket 3.5, Ticket 3.7


Ticket 3.12: Implement Follower and Following List API Endpoints

Objective: This ticket implements the paginated API endpoints for listing a user's followers and the accounts they follow.

Context: These read-only endpoints are crucial for navigating the social graph within the mobile app. They must be performant and support pagination to handle users with large numbers of followers or followings.

Technical Directives:

  • Implement the listFollowers and listFollowing handlers.
  • Both handlers must support cursor-based pagination using the created_at timestamp from the follows table. The cursor should be an opaque string.
  • The limit query parameter should be respected, with a maximum value of 50.
  • Use the store.ListFollowers and store.ListFollowing functions to retrieve the data.
  • Return the data as a list of UserResponse objects along with a next_cursor field.
  • Enforce privacy rules for viewing these lists based on the target user's is_private status.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • A GET request to /v1/users/:userID/followers returns the first page of followers.
  • Providing the next_cursor from a previous response in a subsequent request returns the next page of results.
  • The response structure matches the contract, including the list of users and the next cursor.

Dependencies: Ticket 3.5


Ticket 3.13: Implement Block Management API Endpoints

Objective: This ticket implements the API endpoints for blocking and unblocking other users.

Context: Blocking is a critical safety feature. These endpoints provide the functionality for users to manage their block lists, which immediately severs all visibility and interaction between the two accounts.

Technical Directives:

  • Implement the blockUser and unblockUser handlers.
  • Both endpoints must be authenticated.
  • When blockUser is called, it must create a record in the blocks table and also delete any existing follow relationships between the two users in both directions.
  • When unblockUser is called, it must remove the record from the blocks table.
  • Both handlers should return a 204 No Content status on success.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • An authenticated POST to /v1/users/:userID/block creates a block record and removes any existing follow relationship.
  • An authenticated DELETE to /v1/users/:userID/block removes the block record.
  • After a user is blocked, they can no longer be followed, and they do not appear in lists or search results for the blocker.

Dependencies: Ticket 3.5


Ticket 3.14: Implement User Search API Endpoint

Objective: This ticket implements the GET /v1/users/search API endpoint for finding users by username or display name.

Context: This endpoint exposes the PostgreSQL full-text search capability implemented in the store layer. It allows users to discover other users on the platform.

Technical Directives:

  • Implement the searchUsers handler.
  • The handler must validate the q query parameter (3-30 characters).
  • Call the store.SearchUsers function to execute the search.
  • Before returning results, filter out any users that the authenticated requester has blocked or is blocked by.
  • The endpoint should support pagination via cursor.
  • Return the results in the { users: [], next_cursor: "..." } shape.

Scope Boundaries:

  • services/social/handler module

Acceptance Criteria:

  • A GET to /v1/users/search?q=test returns a paginated list of users matching the query.
  • Users blocked by the requester, or who have blocked the requester, do not appear in the search results.
  • Requests with a query parameter outside the length constraints return a 400 Validation Error.

Dependencies: Ticket 3.5, Ticket 3.6


Ticket 3.15: Wire Up Social Service Application

Objective: This ticket creates the main executable for the Social Service, wiring up the HTTP server, router, middleware, and all dependencies.

Context: This is the final step in making the Social Service a runnable application. It integrates all the previously built modules (handler, store, cache, auth) with the shared platform libraries (db, cache, logger, auth) to create a complete, functioning microservice.

Technical Directives:

  • Create a main.go file in a cmd/social directory.
  • Initialize the structured logger using platform/logger.New.
  • Establish connections to PostgreSQL and Redis using the platform/db.Connect and platform/cache.Connect functions.
  • Load the JWT key pair using platform/auth.LoadKeyPair.
  • Set up an HTTP router and register all the API endpoint handlers created in previous tickets.
  • Implement a JWT authentication middleware that validates the access token on protected routes and places the user's claims into the request context. This middleware will use platform/auth.ValidateAccessToken.
  • The server should listen on a configurable port and handle graceful shutdown.

Scope Boundaries:

  • cmd/social application executable
  • HTTP routing and middleware layer

Acceptance Criteria:

  • The Social Service binary can be built and started without errors.
  • The service successfully connects to PostgreSQL and Redis on startup.
  • Unauthenticated endpoints (register, login) are accessible.
  • Authenticated endpoints return a 401 Unauthorized error if no valid JWT is provided.
  • When a valid JWT is provided, authenticated endpoints function correctly.
  • All endpoints are registered on the correct paths and methods.

Dependencies: Ticket 3.8, Ticket 3.9, Ticket 3.10, Ticket 3.11, Ticket 3.12, Ticket 3.13, Ticket 3.14

Phase 4: Direct Messaging and WebSockets

Phase 5: Push Notifications and Email

Phase 6: Advanced Content Moderation (PhotoDNA + AI)

Phase 7: Analytics and Data Warehouse Export

Phase 8: Search Infrastructure (Elasticsearch)

Phase 9: Admin Dashboard and Tooling

Phase 10: Production Readiness and Auto-scaling