Two routes answer questions about people, in opposite directions, and the difference between them is the first thing to get straight:
| route | direction | sees a deactivated account? |
|---|---|---|
GET /wiki/rest/api/user?accountId=… |
an id → a display name | yes |
GET /wiki/rest/api/search/user |
a name → account ids | no |
markfluence uses the first to render a mention (#91, via client.LookupUser)
and the second to find one to write (#143, via client.SearchUsers). They share
a noun and nothing else. Sharing code between them would take the second's
blind spot and give it to the first, which resolves a departed colleague's name
perfectly well today.
The profile URL a mention points at, and why it is Atlassian Home rather than the site, is in links-and-anchors.md.
Probed against mozilla-hub with an unscoped personal token over the site
domain. Queries below are the cql parameter to
GET /wiki/rest/api/search/user; every request answered 200 unless stated.
| query | result |
|---|---|
user.fullname~"kahn" |
1 row — William Kahn-Greene |
user.accountId="60c36d0718e9f60071326951" |
1 row — the same person |
user.fullname~"kahn" or user.fullname~"reid" |
4 rows, so or parses |
title~"kahn" |
0 rows, 200 |
That last one is the trap this directory opens with: a field the route cannot answer is not an error, it is an empty result, which is indistinguishable from "nobody by that name". A clause added to a query here must be tested by showing it returns fewer rows than the unclaused query, never merely that the request succeeded.
Each row carries user.accountId, user.displayName and user.type.
| query | William Kahn-Greene? |
|---|---|
user.fullname~"kahn" |
yes |
user.fullname~"Kahn-Greene" |
yes |
user.fullname~"kahn*" |
yes |
user.fullname~"william kahn" |
yes |
user.fullname~"ahn" |
no |
user.fullname~"ahn-greene" |
no |
user.fullname~"kahn william" |
no |
user.fullname~"*" matches everybody. A fragment beginning mid-token matches
nothing at all, and the failure is a confident empty result — so a caller has to
be told the rule, which is why user-find's help states it rather than leaving
an author to conclude the person has no account.
requested limit |
echoed limit |
rows returned |
|---|---|---|
| 3 | 3 | 3 |
| 100 | 100 | 100 |
| 101 | 101 | 100 |
| 250 | 250 | 100 |
| 500 | 500 | 100 |
The echo is the dangerous part: nothing in the response distinguishes "your limit was honoured" from "your limit was clipped to 100".
_links holds only base and context — never next, on any page, including
a full one. start is honoured (unlike /search, which ignores it). Walking
user.fullname~"a" at limit=100:
start |
rows | first row |
|---|---|---|
| 0 | 100 | [TEMPLATE] ASCII art generator |
| 100 | 100 | Amedyne Moya |
| 200 | 100 | Bastien Abadie |
| 300 | 4 | Workato Automation |
| 400 | 0 | — |
So 304 people, and a short page is the only end-of-results signal available.
This is why the route cannot go through listV1. That helper asks for
v1PageSize = 250 and terminates when a page is shorter than its request. Here
it would ask for 250, receive 100, and return a truncated set with no error on
any query matching more than 100 people. client.userPageSize exists for this,
with the measurement beside it, and TestPageCapDoesNotTruncate is the
regression.
| request | totalSize |
rows | actual matches |
|---|---|---|---|
limit=3 |
3 | 3 | 304 |
limit=100 |
100 | 100 | 304 |
limit=500 |
100 | 100 | 304 |
It is not an estimate, the way /search's is
(search.md) — it is a
different quantity. Nothing may read it, and "more matches exist" has to come
from asking for one row more than the caller wants.
user.fullname~"reid" returns Ashley Roybal-Reid, Brittany Reid and Kathy Reid,
but not Mark Reid (Deactivated). user.fullname~"lonnen" returns nothing
though . Lonnen (Deactivated) exists and is mentioned on real pages.
sitePermissionTypeFilter cannot lift it:
sitePermissionTypeFilter |
user.fullname~"reid" |
|---|---|
| (absent) | the same three |
none |
the same three |
all |
the same three |
externalCollaborator |
0 rows |
Meanwhile GET /wiki/rest/api/user?accountId=… answers 200 with
Mark Reid (Deactivated) for the same person. So the exclusion is a property of
the directory index, not of the account, and the asymmetry is the point:
markfluence can render a departed colleague's name but cannot find them by one.
user.fullname~"" answers 500 with
java.lang.reflect.UndeclaredThrowableException: null. Same shape as
/search's empty query (search.md),
same remedy: refuse an empty name locally, before any request, so it reports as
the usage error it is rather than as a server failure.
user.fullname~"a" returns [TEMPLATE] ASCII art generator and
Workato Automation alongside people, and every row — those included — reports
"type": "known". The field therefore identifies nothing, which is why
user-find --json does not carry it: a field named type whose only observed
value is known invites a consumer to filter non-humans out with it and get
nothing for the effort.
user.fullname~"kahn" or type=user parses, and the parser accepts backslash
escapes, so internal/client's existing escapeCQL is correct for this route.
Escaping is not optional: an unescaped quote in a name ends the string literal
and the rest of the value becomes query syntax
(search.md).
Verified 2026-09-18 with a scoped service-account token, which is what this took: the earlier probes all used an unscoped personal token, whose 200 said nothing about a scoped token's grants.
The token held the nine scopes the README listed at the time plus
read:confluence-content.all, read:confluence-props,
write:confluence-props and write:confluence-content — ten of them classic,
five granular — and user-find still failed:
HTTP 401: {"code":401,"message":"Unauthorized; scope does not match"}
Every other command worked with that token. So the route's only Current
scope, read:content-details:confluence, is required and is implied by nothing
else — not by the classic read:confluence-content.summary, not by
read:confluence-content.all, and not by search:confluence, which covers the
other two search routes. This is api.md's classic/granular
warning landing exactly where it was predicted to.
It is now in the README's copy-pasteable list. Note the shape of the failure
for anyone debugging one: a 401 whose body says scope does not match, which
is markfluence's third auth phrasing and the one that names the cause plainly.
Both read:confluence-user, which every working token has — unlike the
directory route above. Measured fields: accountId, displayName,
publicName, email, accountType (atlassian for a person, app for a
service account), accountStatus, isExternalCollaborator, isGuest.
accountType is the field worth knowing about: an app account is a
service-account token, is in none of the groups a person is, and is therefore
the explanation for most permission surprises.
The expansion returns the space's id, key, name, type and status.
Do not build the key from the account id. ~{accountId} is right for some
accounts (~60c36d0718e9f60071326951) and wrong for others: this instance
carries personal spaces keyed by email (~aalexander@mozilla.com,
~amuntner@mozilla.com) in the same directory. Both forms are live, so the key
is a lookup and never a format.
It is also genuinely optional: an app account returns personalSpace: null
while a person returns a space, so the field is absent for every
service-account token rather than only in theory.
The expansion also carries _links.webui (/spaces/~60c36d07…,
context-relative like the rest of v1), so the browser URL comes from the
response rather than being assembled. That matters for the same reason the key
does: an email-keyed personal space links as /spaces/~amuntner@mozilla.com
with the @ unescaped, so building the path locally would mean inventing
an escaping rule, and getting it wrong for exactly the keys that are already
the awkward case.
The operations expansion works on the collection, not only the single-space
route, so the survey is one walk rather than one request per space. create:page
means the account may create pages there; administer:space means it
administers the space — an admin's row also carries archive:space,
delete:space, export:space and six manage_* operations, so the one
Atlassian names for the permission is the one that will not drift.
Measured with two accounts: a scoped service-account token saw 525 spaces, 97 writable, 2 administered; a personal token saw 533, 102, 11.
The route answers for the authenticated credentials and takes no account
id, which is why user-info surveys spaces only for its no-argument form, rather
than reporting the caller's own access beside a named account's name.
Asking it for another account is possible and expensive, which is worth
writing down so nobody re-derives it. Both pieces exist and both answer a
non-admin: GET /rest/api/user/memberof?accountId= lists an account's groups
(42 for one person here) and GET /api/v2/spaces/{id}/permissions lists a
space's grants as principal/operation pairs. But the grants are per space,
run 250+ rows each and paginate, and there are 533 spaces — over a thousand
requests against three for the caller's own answer. It also means resolving
Confluence's permission model locally: group grants, individual grants,
defaults, space versus global. A subtle error there reports another person's
access with full confidence, which is the failure mode this directory exists to
prevent.
Neither is page edit, which is not a space property at all
(page-status.md): create:page is a space grant and page
update appears only on a page's own operations.
The trap, and the reason this route does not go through listV1.
limit=250&start=0 -> 200 rows
limit=250&start=200 -> 250 rows
limit=500&start=0 -> 500 rows
The real total was 525. listV1 stops when a page comes back shorter than it
asked for, so under it this route reports 200 spaces and 39 writable,
silently, with no error — which is exactly what the first version of this probe
did before start=200 was tried. Termination here is an empty page and
nothing else, and the offset advances by rows returned rather than by the
limit requested, because the two differ.
This is the README's opening trap arriving from a new direction: the cheap answer looked right and was not.
- Whether
sitePermissionTypeFilterhas any effect at all. Three values were measured and none changed a result set. It may matter on an instance with external collaborators; this one appears to have none matching the probes. - Paging past 304 rows. The largest result set available here terminated
well before any plausible server-side ceiling on
start.start=1000returned 0 rows rather than an error, which is consistent with "past the end" and proves nothing about a deeper limit.
- The two user routes stay separate.
LookupUserresolves any account;SearchUserssearches an index that omits deactivated ones. Neither is a special case of the other. - This route gets its own pager and its own page size. Not
listV1, whose 250-row request the 100-row cap would turn into silent truncation. - Nothing reads
totalSize. "More exist" is a flag derived from fetching one row more than was asked for, anduser-find --json'ssummary.truncatedis that flag. - The match semantics are reported, not worked around. A word-prefix, ordered match is the server's behaviour; padding a query with wildcards to fake substring matching would change which people a name finds and make the command's answer depend on markfluence's guess.