Four visibility levels beat a page of toggles
Passion, 23 June 2026
Open the privacy settings of most social products and you get a wall of switches. Show my email. Show my phone number. Let people find me by email. Show my activity. Show my connections list. Each one is individually reasonable and the page as a whole is unusable, in the specific sense that nobody who has read it can tell you what a stranger currently sees. Ask a user to predict it and they guess. Ask the team that built it and, in my experience, they open a second account to check.
That is not a failure of copywriting. It is what happens when you model privacy as a set of independent booleans.
The state space nobody tested
Twelve independent switches produce 4,096 possible configurations. Realistically your test suite covers perhaps a dozen of them, all of them near the defaults, and your manual testing covers whichever ones somebody thought of on the day. The remaining several thousand are live in production and unexamined.
Most of those states are harmless. A few are not, and they are hard to find precisely because they involve unusual combinations: the switch that hides your profile from search but not from the suggestions panel, the one that hides your employer on the profile page while the same string is still rendered in a comment header.
There is a second cost that matters more commercially. A user cannot form a mental model of 4,096 states, so they cannot form trust. When an organisation asks whether it is safe to put its members' details in your product, "there are settings for that" is not an answer. "Here are the four things a profile can be" is.
An ordered ladder
The alternative is to make visibility a single value from a short ordered list, broadest to narrowest:
- Public. Anyone, including people who are not signed in.
- Organisation. Anyone who is a member of the same organisation.
- Connections. People this member has accepted a connection with.
- Private. Only the member.
Four states. Each is one sentence, and each sentence names a group of people the user can actually picture. That last part is the whole reason it works. "Connections" is a set of humans you approved one at a time. "Show my activity" is not a group of people, it is a category of data, and users are much worse at reasoning about categories of data than about rooms full of people.
Why four and not three or six. Three drops the organisation level, which for a product scoped to organisations removes the most useful setting there is. Six means naming two levels adjacent enough that nobody can articulate the difference, which puts you back where you started with a smaller state space.
Where the ladder quietly lies
An ordered list implies each level is a subset of the one above. Public contains organisation contains connections contains nobody. That is a clean model and it is not automatically true, because "in my organisation" and "connected to me" are two overlapping sets, not two rings.
Consider a member who has a connection at a different organisation. If you treat the ladder as a plain ranking and give that person "connections" clearance, they now clear the organisation level too, and they can see content that was labelled organisation-only. Nobody intended that, and the setting screen certainly did not suggest it.
You have to pick a nesting and enforce it. The choice we made is that connection-level access also requires shared membership, so the ladder is genuinely nested and the sentence on the settings screen is true. The cost is that an outside connection sees less than the user might expect. That is the right way round: a surprise where somebody sees less than expected produces a support message, and the other direction produces an incident.
Encode it once, on the server, in one function:
// Broadest to narrowest. The order is the design: a viewer sees
// everything at or below the level they clear.
const LEVELS = ['public', 'organisation', 'connections', 'private'] as const;
type Level = (typeof LEVELS)[number];
const rank = (l: Level) => LEVELS.indexOf(l);
function clearance(viewer: User | null, owner: User): Level {
if (!viewer) return 'public';
if (viewer.id === owner.id) return 'private';
// Connections only outrank organisation when the connection is also a
// member. Otherwise the two sets overlap and the ladder is not nested.
if (sameOrg(viewer, owner) && connected(viewer, owner)) return 'connections';
if (sameOrg(viewer, owner)) return 'organisation';
return 'public';
}
const canSee = (viewer: User | null, owner: User, level: Level) =>
rank(clearance(viewer, owner)) >= rank(level);
Twenty lines, one place to read when somebody asks how this works, one place to change when the policy changes, and one place to write tests against. Four levels times five viewer relationships is twenty test cases, which is a suite you can actually write and keep passing.
Two axes, on purpose
Profile visibility answers "who can see who I am". Post visibility answers "who can see this thing I just wrote". They are separate questions and merging them is a mistake people make in the name of simplicity.
A member might keep a quiet profile and still want to post to their organisation. Somebody else might have a public profile and want one particular post kept inside the walls. If there is one control, one of those two people cannot do what they want.
Keeping the axes separate costs almost nothing, because the post selector can be shorter: anyone, organisation only, connections only. Three options against four levels is twelve combinations, and unlike 4,096 that is a table you can print out and check.
Where the leaks actually are
The visibility check on the profile page is the easy part and it is never where things go wrong. Everything else that renders a person is where you get caught:
- Search results, which usually run as their own query with their own filter.
- "People you may know" suggestions, which by their nature surface people you have no relationship with.
- Mention autocomplete, which is a search endpoint that nobody thinks of as one.
- Notification text, where a name and a headline get embedded in a string.
- Data exports and API responses that serialise a whole record rather than a permitted view.
- Cached fragments and preview images generated before the viewer was known.
The pattern that keeps this manageable is to make the permitted view of a profile the only object the rest of the application can obtain. If there is no way to get the raw record outside the data layer, the suggestions endpoint cannot leak a field it never received.
What we have not built
There is no view-as feature in ClassProfile. Being able to see your own profile as a stranger, as a colleague, as a connection, is the single control that would let a user verify the model rather than trust it, and it is not there yet. The four levels make it explainable. A preview would make it checkable, and those are not the same thing.
Search is also honest about being simple: case-insensitive substring matching scoped to the caller's organisation, not ranked full-text search. It works at current sizes and it is on the list of things that will need replacing before it stops working. Any rewrite of it has to carry the visibility rules across, which is the part that makes a search rewrite more expensive than it looks.
Further reading: ClassProfile.