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,urnand the profile link are visible to any authenticated viewer. The profile link is derived client-side from thehandle; the server exposes no separate link field. External identifiers (githubUsernameand similar) are likewise public.nameandemailare 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 isADMINorOWNER. ACONTRIBUTORorREADERco-member seesnullfor 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/OWNERsees 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.
handleand an external username (e.g.githubUsername) match on any case-insensitive substring — you can look someone up by a fragment of their handle. emailmatches 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, androleare nullable. Anullmeans "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/emaileverywhere 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.