Appearance
Users & Groups — API Analysis (API-A)
Concept layer — frozen. The Users & Groups generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Feature: Users & Groups
Paths below carry no version or gateway prefix: how a project versions and mounts these routes is an application decision, not part of the concept. Success envelope: { data, status, message, requestId }. An error carries a stable machine-readable code alongside the human-readable message, and a refusal names the capability the caller was missing — which turns "why can't I?" into a support answer rather than a ticket.
Two surfaces, not one
The directory is read far more widely than it is written, and the two audiences want different things. The concept separates them:
| Administrative surface | Consuming surface | |
|---|---|---|
| Who calls it | directory administrators | every feature with an assignee or a mention |
| Operations | create, update, activate, deactivate, delete, curate membership | read only |
| Reach | everyone the administrator may see | the people visible in the caller's scope |
| Fields | everything, including audit columns | the least a name can be rendered from |
Merging them yields one endpoint whose behaviour depends on the caller's capabilities, which is hard to document and harder to test. Keeping them apart also keeps the administrative capabilities away from ordinary users, which is where the interesting leaks come from.
Everything below is described for people. Groups are symmetric — same shapes, same pagination, same activate/deactivate/delete semantics — and are called out only where they differ.
Endpoint index: the git host renders an outline from the
##headings below — there is no hand-maintained endpoint list to fall out of date.
1. List people
Endpoint: GET /users
Description
Paginated listing, for administration tables and for pickers that can page.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page / pageSize | Integer | No | Documented defaults; page size has a hard maximum |
active | Boolean | No | Filter by usable state |
displayName | String | No | Case-insensitive substring |
contact | String | No | Case-insensitive substring |
sort | Enum | No | From a closed list |
An unrecognised filter or sort value should be rejected, not quietly treated as absent. Treating it as absent turns a typo into a listing of everybody, which is the wrong direction for a mistake to fail in.
Request Logic
- Excludes deleted records and applies the scope predicate — whose shape follows from the scoping decisions on the feature page.
- Runs a count query and a page query. They share no snapshot, so a concurrent write between them can make the reported total disagree with the rows returned. Harmless in a table, and worth knowing before it is reported as a defect.
Success Response (200 OK)
json
{
"data": {
"users": [
{
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"displayName": "Anna Novak",
"contact": "anna.novak@example.com",
"active": true
}
],
"page": 1,
"pageSize": 20,
"totalCount": 1,
"totalPages": 1
},
"status": 200,
"message": "Fetched users.",
"requestId": "7b0a1a41-fd2a-4bd9-9c2c-0f57db3e3f15"
}2. List people without pagination
Endpoint: GET /users/all
Description
The same listing, unpaginated, for dropdowns and mention suggestions — surfaces that cannot page.
Request Logic
Identical predicate and filters, and no limit. Response size grows with the directory, with no back-pressure and nothing watching it.
This is the endpoint that will eventually hurt. It is acceptable only under a stated assumption about directory size, and that assumption belongs next to it. Two ways out when it stops holding, both better than raising a limit: make it a search that requires a couple of characters and caps its results, or return only identifiers the client can already resolve. Decide which before it is needed.
Success Response (200 OK)
The same objects as the paginated listing, without the pagination metadata.
3. Read one person
Endpoint: GET /users/{userId}
Description
Detail for the administration panel. Typically the only read returning the avatar reference — a table does not need it, and it is the largest field.
Error Responses
| Status | When |
|---|---|
400 | Malformed path identifier — rejected before any query runs |
404 | No live person holds that identifier |
Error messages leak the surface they were written for. A not-found message mentioning a scope, returned by an endpoint that has no scope, means the code was shared between the two surfaces without its wording being revisited. Cheap to get right, and quietly confusing for years if not.
4. Create a person
Endpoint: POST /users
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
displayName | String | Yes | Shown wherever the person appears |
contact | String | Yes | Format- and length-validated; unique among live records |
avatarRef | String | No | Absolute reference; defaults to empty |
active | Boolean | No | Defaults to true |
provisionSignIn | Boolean | No | Also create the external sign-in account; defaults to true |
Request Logic
- Validates the contact detail before touching the database.
- Checks that no live person already holds it, compared case-insensitively. The constraint is the real guard; this check exists only to produce a friendlier error than a unique violation.
- Writes the record, then — unless opted out — creates the external account and links it.
- On provisioning failure, retires the record just written. On linking failure, keeps both and logs for manual repair. See the feature page, Provisioning a sign-in account alongside the record.
Transactional Operations
The insert commits before the external call. The two systems are reconciled by compensation, not rollback: a second write undoes the first. A process that dies between them leaves a live record with no sign-in account — the one inconsistency this shape can leave, and one to state rather than let a reader discover.
Success Response (201 Created)
json
{
"data": { "userId": "bbbbbbbb-0000-4000-8000-000000000010" },
"status": 201,
"message": "Created user.",
"requestId": "f2d93550-51f5-4e14-95b2-4fef7d9f1a2a"
}Error Responses
| Status | When |
|---|---|
400 | The contact detail fails format or length validation |
409 | A live person already holds it — from the pre-check, or from the constraint when two requests race |
500 | The external identity system refused; the record created moments earlier has already been withdrawn |
5. Update a person
Endpoint: PUT /users/{userId}
Description
Replaces the display name, contact detail, avatar and usable state.
Request Logic
- A replace is not a merge. Omitting the avatar clears it. A project preferring partial updates should use
PATCHand say so — what it must not do is name the methodPUTand behave likePATCH, because a caller will eventually omit a field it meant to keep. - Matches on the identifier excluding deleted records, and reports "matched no row" as not-found, which is how a deleted or unknown person surfaces.
- Takes no lock and re-reads nothing: concurrent edits resolve last-writer-wins with no detection. Defensible for a directory — state it, rather than implying an optimistic locking that is not there.
6. Activate and deactivate a person
Endpoints: POST /users/{userId}/activate · POST /users/{userId}/deactivate
Description
Separate endpoints rather than a flag on the update, because they are separate acts deserving separate capabilities: an administrator may be trusted to suspend someone without being trusted to rename them.
Request Logic
- Idempotent — activating an already-active person succeeds and moves only the timestamp.
- Deactivation does not by itself end an existing session. Whether it should is a project decision, and whichever way it goes, the caches involved must be named. Silence here is how a suspended person keeps working until their session happens to lapse.
Success Response (200 OK)
Returns the state it set. Note set, not re-read — a response that must reflect the row as it now stands has to be returned by the write itself.
7. Delete a person
Endpoint: DELETE /users/{userId}
Description
Removes the person from the directory and withdraws their ability to sign in. The record is retained — see the feature page, Deactivation is not deletion.
Request Logic
- Reads the person's identity links first, so their cached lookups can be cleared afterwards.
- Locks the row, marks it deleted and inactive, and marks every identity link deleted, in one transaction. The lock is what makes a concurrent double-delete safe: the second caller finds no live row and gets not-found.
- Clears the cached identity lookups after the commit, so a failure there leaves the person deleted while a cached lookup may briefly still resolve to them.
- What else the deletion touches — memberships, access grants — is the decision described on the feature page, and belongs in this endpoint's documented logic. It is the difference between a clean removal and a deleted person who still holds access.
Success Response (204 No Content)
No body.
8. Groups — where they differ
Endpoints: GET /groups · GET /groups/all · GET /groups/{groupId} · POST /groups · PUT /groups/{groupId} · POST /groups/{groupId}/activate · POST /groups/{groupId}/deactivate · DELETE /groups/{groupId}
Same shapes as the person endpoints, with a name in place of a display name, and no contact detail, no avatar and no external provisioning — which removes the only multi-system step in the capability and leaves group creation genuinely atomic.
Four differences worth documenting rather than leaving to be inferred:
- Group names are usually not unique, and nothing arbitrates concurrent creation, so two live groups can share a name. That is tolerable for a set that is chosen from a list and referenced by identifier, and intolerable if anything resolves a group by name. Decide, and say so.
- A group whose scope is derived rather than stored has no single answer to where it belongs — and the two derivation rules a project ends up with will disagree. See the feature page, Scope membership.
- Deactivating or deleting a group withdraws whatever access it granted its members, so both must invalidate the access-decision cache. Only a rename may skip it.
- Deleting a group does not delete its members, and leaves the membership rows pointing at a deleted group. They stay out of reads only because every membership query joins the group and requires it live — which is a load-bearing detail, not an incidental one.
9. Add people to a group
Endpoint: POST /groups/{groupId}/members
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
userIds | UUID[] | Yes | Non-empty; every identifier must name an existing person |
Request Logic
- Refuses if the group does not exist or is not live.
- Refuses the whole addition if any named person is missing. A partial success leaves the caller unable to say what happened.
- Skips people already in the group, so repeating the call is harmless.
- Deduplication must rest on a uniqueness constraint over the group-and-person pair, not on reading the membership list inside the transaction. Two concurrent additions each read a list without the other's row, and both insert. The application check is a friendlier error, never a guarantee.
- On commit, invalidates the access-decision cache for every person added, in every scope the group's grants reach.
- Follow-up work — seeding a new member's notification preferences, for instance — is best-effort: logged when it fails, never fatal to the addition.
Success Response (201 Created)
Returns the memberships actually created, so a caller can tell what was new from what was already there. An empty list therefore means everyone requested was already a member — which is only unambiguous because the group's existence was checked first.
10. Remove people from a group
Endpoint: DELETE /groups/{groupId}/members
Request Logic
- Marks the membership rows removed rather than erasing them, so "who was in this group in March" stays answerable.
- Returns the rows actually affected, and checks the group exists first. Without that check an empty result is ambiguous: it means either "nobody named was a member" or "there is no such group", and the caller cannot tell which.
- Invalidates the access-decision cache on the same terms as the addition. Removing a member is an access-removal act; forgetting to invalidate here leaves the withdrawn access live.
A body on a
DELETEis a wart. Removing a batch needs a list, and aDELETEconventionally carries none. The alternatives — one call per person, or aPOSTto a removal sub-resource — are each awkward in their own way. Whichever a project picks, pick it consistently with the addition and record why.
11. The assignable-people read
Endpoint: GET /users/assignable
Description
The read every consuming feature calls to populate an assignee picker. It is the one endpoint here reachable with an ordinary user's capability rather than an administrator's, which makes it the highest-risk read in the capability.
Request Logic
- Live, active people in the caller's scope, carrying the name, contact and avatar a picker renders.
- The scope predicate is the whole endpoint. An assignable-people read that omits it returns the entire directory — every name and contact detail in the system — to any ordinary user. It looks identical to the correct version in every respect but the predicate, it passes every functional test, and nothing in the response reveals the fault.
- Returning contact details is a genuine trade-off: it is what disambiguates two people with the same display name, and it exposes everyone's contact detail to everyone who can open a picker. A project may prefer a partially masked form; either way, state the choice.
Success Response (200 OK)
json
{
"data": {
"users": [
{
"id": "bbbbbbbb-0000-4000-8000-000000000001",
"displayName": "Anna Novak",
"contact": "anna.novak@example.com",
"avatarRef": null
}
]
},
"status": 200,
"message": "Listed assignable users.",
"requestId": "510c4619-e02e-7bb9-0183-e6336d1ff989"
}Endpoints that sit here but belong elsewhere
A person's detail path attracts sub-resources owned by neighbouring capabilities: their external identity links, their scope memberships, the access they effectively hold. They are routed under the person because that is where an administrator looks for them, and they are documented with the capability that owns them, not here.
Listing them at the foot of this page — endpoint, capability code, and where each is documented — costs a table and saves the next reader from concluding that the directory owns authentication.