Skip to content

User profile visibility

When one user reads another user's profile, the fields they get back depend on their relationship to that user. The rules are enforced server-side — the same gate applies to GraphQL queries, the MCP tools, and any future surface, so a client never has to mask fields itself.

Suppressed fields come back as null, never an error. A query for a stranger's email succeeds and returns null; the request does not fail, return an HTTP 403, or omit the field.

Visibility matrix

Field Any authenticated user Co-member (shares an org with the target) Co-member and viewer is ADMIN/OWNER of the shared org
id, handle, urn, profile link
name, email
role (target's role in the shared org)
  • id, handle, urn and the profile link are visible to any authenticated viewer. The profile link is derived client-side from the handle; the server exposes no separate link field. External identifiers (githubUsername and similar) are likewise public.
  • name and email are visible only when the viewer shares at least one organization with the target. Sharing any org is enough — visibility is not scoped per-org.
  • role — the target's role within a shared org — is visible only when the viewer shares that org with the target and the viewer's own role in that same org is ADMIN or OWNER. A CONTRIBUTOR or READER co-member sees null for the role.

Two cases override the matrix:

  • You always see your own full record. Every field is visible when the viewer is the target.
  • A platform ADMIN/OWNER sees everyone's full record. Platform elevation bypasses the co-membership gate entirely.

App-key callers (no user in context) are treated as having no relationship: they see only the always-visible fields.

Discovery scoping

User search must let you add a known person without becoming an enumeration oracle. Matching is scoped by field:

  • Public identifiers are substring-searchable. handle and an external username (e.g. githubUsername) match on any case-insensitive substring — you can look someone up by a fragment of their handle.
  • email matches only as an exact, whole-value match for a viewer who isn't a co-member. A partial or fragment never matches an email, so you can add a colleague by their full address but can't fish for addresses by prefix. (The match is case-insensitive.)
  • A name-only match is withheld from non-co-members. If the only reason a user matched your query was their name, and you don't share an org with them, that result is dropped. Co-members and platform admins still match by name.

Search returns at most 20 results and there is no offset/cursor pagination — narrow the query rather than paging.

For API and MCP consumers

  • Handle nulls, not errors. name, email, and role are nullable. A null means "not visible to you," not "absent" — don't treat it as an error or retry.
  • Co-membership visibility is global, not per-org. Sharing one org with a user reveals their name/email everywhere they appear, not just within that org.
  • The guarantee is server-side. Because the gate lives in the resolver layer, GraphQL and MCP return identical results for the same viewer. You don't need to (and shouldn't) re-mask fields in a client.

Where this comes from

These rules are spec cor:acl:070 in the hadronmemory.com::specs memory (cor:acl:070:01 for the visibility matrix, cor:acl:070:02 for discovery scoping). They shipped in hadron-server #384 (PR #385).

See also Memory access for how org membership and roles govern access to memories, and Access checks for auditing a principal's effective access to a resource.