Problem RestatementProblem
Meta asked: design the privacy system for social media posts. Each post has an audience:
- Public: anyone.
- Friends: only the author's friends.
- Friends of friends (optional).
- Only me.
- Custom: specific people or lists allowed, with some excluded ("Friends except Alex").
Privacy must be enforced everywhere posts show up: news feed, profile pages, search, notifications, shares and comments, and must react correctly when someone unfriends or the author changes the setting.
The Core Check
can_view(viewer, post):
if viewer == post.author: return True
if viewer blocked by / blocking author: return False
switch post.audience:
PUBLIC: return True
ONLY_ME: return False
FRIENDS: return are_friends(viewer, author)
FRIENDS_OF_FRIENDS:return are_friends(viewer, author) or share_friend(viewer, author)
CUSTOM: return (viewer in allow_list or viewer in allowed_lists) and viewer not in deny_listRules to state: deny beats allow, blocking beats everything, and the author can always see their own post.
Data and ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
SVC["Feed / profile / search / notifications"] --> PC["Privacy check service - batch API"]
PC --> FG[("Friend graph - cached adjacency")]
PC --> PA[("Post audience - stored with post")]
PC --> BL[("Block lists")]
PC --> CL[("Custom lists")]
ED["Privacy edits / unfriend events"] --> INV["Cache invalidation"]
INV --> PC- Post audience is stored with the post:
audience_type,allow_ids,deny_ids,allowed_list_ids. - Friend graph: a sharded graph store with an in-memory cache of each user's friend set.
are_friends= a set lookup. "Friends of friends" = check whether the two friend sets intersect (bounded work, with caching). - Privacy check service exposes a batch API (
can_view(viewer, [post_ids])), because feeds check hundreds of posts at once.
Deep Dive — Enforcing the audience on every surfaceDeep dive
A post is "Friends only". That has to hold in the feed, on the profile, in search, in notifications, in shares and in the API. One surface that forgets is a privacy incident.
Filter in the feed service
The feed applies the visibility rule as it assembles posts.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
POST["Friends-only post"] --> FEED["Feed - filtered correctly"]
POST --> PROF["Profile page - separate code path"]
POST --> SRCH["Search results - separate code path"]
POST --> NOTIF["Notification preview - separate code path"]
PROF --> LEAK["Any one of these missing the check leaks the post"]
SRCH --> LEAK
NOTIF --> LEAKThe rule lives where someone remembered to put it. Every new surface is a new chance to omit it, and the omission is invisible until someone notices their private post somewhere public.
One check, applied at every read boundary, with the index shaped to help
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
CAND["Candidate posts generated broadly"] --> BATCH["Batch canSee(viewer, posts) before results are returned"]
BATCH --> RANK["Rank only what survives"]
SRCH["Search"] --> SPLIT["Public posts in a public index"]
SPLIT --> FR["Friends-only: filter by author in viewer's friend set at query time"]
FR --> EXACT["Then the exact check on the final page"]
SHARE["Reshare"] --> NARROW["Audience of a share can never widen the original"]
CACHE["Rendered feed"] --> NEVER["Never cached across viewers"]- Filter before ranking, in a batch. Visibility is a precondition, not a ranking signal — and batching keeps it one call per page instead of one per post.
- Partition the search index by audience. Public posts live in a public index; restricted posts are narrowed at query time by the viewer's friend set, then confirmed by the exact check on the returned page. Filtering after retrieval alone means private content has already been scored and counted.
- A share can never widen the audience. Resharing a friends-only post makes it visible to people who could already see it, never beyond — the most common way privacy is lost, because it looks like a feature.
- Never cache a rendered feed across viewers. A cache key without the viewer in it is the fastest possible route to showing one person another person's private posts.
The structural point: make the check the only way to read posts — a data access layer that requires a viewer — so bypassing it is impossible rather than merely discouraged.
Changes and Consistency
- Author changes the audience: update the post's audience, and invalidate caches and precomputed feeds that included it. Future requests use the new setting immediately, since checks happen at read time.
- Unfriend: update the friend graph, and invalidate both users' friend-set caches. Friends-only posts stop showing right away, because checks read the current graph.
- Why check at read time: precomputing "who can see what" is huge and goes stale. A fast check with cached friend sets is simpler and always current.
Wrap-UpWrap-up
Store each post's audience (type + allow/deny lists) with the post, and evaluate a clear can_view rule set (author sees own, blocks and denies win, then public, friends, friends-of-friends or custom lists) against a cached, sharded friend graph through a batch privacy service. Enforce it at read time in every surface (feed, profile, search, notifications, shares), never let shares widen the audience, re-check at the response layer, and invalidate caches on audience changes and unfriending.