Appearance
Users & Groups
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.
Business Context
A system that assigns work, notifies anyone, or records who did what needs its own record of the people involved. Identity — proving that someone is who they claim — comes from elsewhere. This concept owns the rest: who exists, what they are called, and which named sets they belong to.
Business-Level Definition
A user is one person, held once. That record carries the name and contact detail every other part of the product shows in place of an internal identifier, so the same person is recognisably the same person wherever they appear.
A group is a named, reusable set of people, so that responsibility can be handed to a role rather than to an individual. Work assigned to a group keeps flowing when a member leaves, joins or goes on holiday, without anyone having to find and repoint every assignment that named them.
The boundary is the whole point of the concept, and it has two edges:
- Not who may do what — that is the access-control capability, which treats these records as the subjects it grants to.
- Not how someone proves who they are — that is the authentication capability, which holds credentials and external identity links. No credential material is stored here.
What is left over is small, and that is the design: a directory is valuable in proportion to how boring it is.
Requirements Definition
- One place to see who exists, add someone, correct their details, and retire them.
- Named sets of people, curated by administrators, that anything with an assignee can point at.
- Human-readable names for display, with everything else in the system referencing stable identifiers — so renaming a person breaks nothing.
- A read that answers "who can I assign this to?", which every consuming feature calls instead of keeping its own list.
- A record of directory changes, since adding someone to a group is an access change in disguise.
Technical Context
User Stories / Use Cases
- As an administrator, I add a person so that they exist and can be granted access.
- As an administrator, I correct a person's display name or contact detail so that what the product shows stays right.
- As an administrator, I deactivate a person so that they can no longer be assigned work, without erasing what they did.
- As an administrator, I create a group, rename it, and add or remove its members.
- As an administrator, I review what was changed on a person or a group, in order, and by whom.
- As a consuming feature, I fetch the people and groups available here so I can offer them as assignees or mention targets.
Functional Requirements
The person record
- Display name, contact detail, an optional avatar reference, and an active flag. Nothing else belongs here by default — every field added to a directory record is a field some other capability wanted and should probably have owned.
- No credentials, tokens or authentication material. The link to an external identity is held by the authentication capability and referenced, not embedded.
- Whether the record is bound to a scope or held once for the whole system is a project decision: see Is a person scoped, or held once?
The group record
- A name and an active flag. A group is a name and a membership list; anything more is a responsibility that belongs to whatever consumes the group.
- Groups follow the same scoping decision as people, and for the same reasons. Splitting them — global people, scoped groups, or the reverse — produces a membership relation that crosses a boundary in one direction only, and every query then has to decide which side wins.
Uniqueness of the contact identifier
- The contact detail that identifies a person — typically an email address — is unique, compared case-insensitively.
- Uniqueness applies only among live records. A retired person's address is therefore free for a new person to take. That is usually what an administrator wants, and it has a consequence worth stating: after reuse, two records hold the same address, one live and one retired, and any lookup by address must be explicit about which it means.
- Enforce it with a database constraint over live rows, not only with a check before insert. The pre-check exists to produce a friendly error; the constraint is what makes two simultaneous creations safe.
Membership is a set
- Adding people who are already members is harmless and changes nothing — the operation is idempotent, because callers retry and administrators double-click.
- If any named person does not exist, the whole addition is refused rather than partly applied. A partial success leaves the caller unable to say what happened.
- Removal marks the membership row removed rather than erasing it, so "who was in this group in March" stays answerable.
- Deduplication belongs in a constraint, not in application code. Checking for existing members inside the transaction and inserting the rest looks correct and is not: two concurrent additions both read a membership list without the other's row and both insert. A uniqueness constraint on the pair is the only version of this that holds.
Deactivating and deleting
Two distinct acts, with distinct meanings — see Deactivation is not deletion.
Provisioning a sign-in account alongside the record
Where the directory is the administrative front door, creating a person often also creates their account in the external identity system, so they can sign in immediately. This spans two systems and therefore cannot be atomic. The concept's position:
- Make it opt-out, so a directory-only entry — someone who must be assignable but never signs in — remains expressible.
- Reconcile by compensation, not rollback: write the record, create the external account, and if that fails, retire the record just written. A process that dies between the two steps leaves a live record with no account, which is the one inconsistency this shape can leave and should be stated rather than discovered.
- Treat failure to link an account that was successfully created as non-fatal: both objects exist, the request succeeded, and the link is repairable by hand. Failing the request there would destroy an account that is now orphaned.
- A project that cannot accept that residue should invert the flow — provision first, then write the record — and inherit the opposite residue: an orphaned external account.
Reads that other features depend on
- A paginated list for administration tables, with filters on name, contact and active state, and sorting from a closed list.
- An unpaginated variant for pickers, dropdowns and mention suggestions, which cannot page.
- The unpaginated variant is the growth constraint on the whole capability and should be capped or made a search rather than a dump. It degrades linearly with headcount, silently, and there is rarely a defined point at which someone notices.
- Every scoped read must apply its scope predicate. Where scope is derived rather than stored (see below), a read that omits it is indistinguishable from one that could not express it — which is what makes this the likeliest place for a cross-scope leak.
Auditing
- Creating, updating, activating, deactivating and deleting a person or a group, and every membership change, belong in the system's shared append-only audit trail.
- A membership change is filed against the group, with the member as the entry's subject. The membership row itself is not something anyone navigates to and its identifier would be meaningless in a history. Carrying the member as the subject is what makes "which groups was this person put in, and by whom" answerable at all.
- Person entries name the person as both the record and the subject. The duplication is deliberate: without it, a per-person query becomes a disjunction across two column pairs that no index serves.
- Recorded context carries changed fields as before/after pairs and display names as they read at the time. A deletion has no after value; it records what stood at the moment of deletion.
- Do not file an entry against a derived scope. Where a record's scope is inferred per query rather than stored, writing that inference into an append-only trail bakes today's inference rule into a record that outlives it. File such entries at the system level and accept the consequence — scoped administrators cannot see them — rather than making history depend on a rule that may change. The prohibition is on inference, not on scope: where the change itself carries a scope as a stored attribute, file against it, which is what Permission Model requires of an assignment entry.
Non-Functional Requirements
- Availability. This is a read dependency of assignment, mentions, notification and access resolution. It has no meaningful degraded mode: if it cannot be read, all of them stop. That is a property to state, not to mitigate within this capability.
- Scale. Sized for the headcount of the organisations using the system, not for consumer numbers. That assumption is what makes the unpaginated reads tolerable, and it should be written down so the day it stops holding is recognisable.
- Indexing. Substring filters on name and contact, and sorting by name or timestamp, are the hot-path reads and are the ones most often left unindexed — they work fine on a directory of fifty.
Domain Model
erDiagram
users {
uuid id PK
text displayName
text contact "unique among live rows, case-insensitive"
text avatarRef "nullable"
boolean active
timestamptz deletedAt "nullable"
}
groups {
uuid id PK
text name
boolean active
timestamptz deletedAt "nullable"
}
groupMembers {
uuid id PK
uuid groupId FK
uuid userId FK
timestamptz deletedAt "nullable"
}
scopeMembers {
uuid id PK
uuid scopeId FK
uuid userId FK
timestamptz deletedAt "nullable"
}
users ||--o{ groupMembers : "is a member through"
groups ||--o{ groupMembers : "has members through"
users ||--o{ scopeMembers : "belongs to a scope through"
Owned here: the person, the group, the membership between them, and — where people are held once rather than per scope — the membership between a person and a scope. That fourth relation is what Scope membership: a stored fact, or derived from access? requires, and the scoping concept expects this capability to own it. Three entities where each scope keeps its own people, four in the common case, and the concept is finished there. A project that chooses derived scope membership instead does not merely leave the fourth relation unbuilt — it removes it from its model, so that no class, endpoint name or diagram implies a table that does not exist. See Scope membership, the second trap.
Referenced, owned elsewhere: the external identity link (authentication), the access assignments that name a person or group as their subject (access control), and the audit entries this capability writes. None of them is modelled here, and the arrows point at the directory, not out of it — a directory that reaches into its consumers has stopped being one.
API Analysis (API-A)
See child page: Users & Groups — API Analysis (API-A).
Data
The directory needs no reference data. It starts empty and is filled by administrators. What a fresh deployment does need is one person who can sign in and administer it — seeded with an identity link and an administrative grant, since a directory record alone gives nobody access to anything.
Sample and seed data must be anonymised: made-up names on a made-up domain. A directory is the one place where real personal data is most likely to be pasted into a fixture.
Logging
.error
- on any directory operation failing, naming the operation.
- on a record being withdrawn after external provisioning failed — naming the record, since a person the administrator believed they had created no longer exists.
- on an external account being created but not linked — non-fatal, and the only record that a manual repair is needed.
.warn
- on best-effort follow-up work failing after a membership change, such as seeding a new member's notification preferences. Non-fatal by design; silent would be wrong.
.info
- on create, update, activate, deactivate and delete of a person or a group, and on membership changes.
A capability whose success paths log nothing is relying entirely on its audit trail. That is a defensible design — the trail is the queryable record, and duplicating it into logs is noise. It stops being defensible the moment the trail is specified but not yet written: then a directory change leaves no record anywhere, and nobody notices, because both halves look like somebody else's job.
Caching
- Directory reads are usually not cached. They are cheap, and staleness in a name is more confusing than a query.
- This capability invalidates other people's caches. Adding or removing a group member changes what its members may do, so the decided-capabilities cache owned by Permission Model must be invalidated for every person affected, in every scope the group's grants reach — a dependency that concept states from its own side too.
- So must deactivating or deleting a group. These are access-removal acts exactly as much as removing a member is. Invalidating on the membership paths and not on these leaves withdrawn access live for the lifetime of the cache — a gap that is invisible in testing, because the cache is cold.
- Renaming a group changes no access and correctly invalidates nothing.
- Deleting a person clears their cached identity lookups, so a live session cannot continue to resolve to them.
Legal Context
A directory holds personal data about identifiable people; that is what it is for, and no configuration makes it otherwise. None of the following is answerable from the design, and each answer changes what the design has to do.
- Which regime applies, and to whom? That depends on where the people are, where the records are stored and processed, and what sector the deployment serves. The design cannot supply it and should not be read as implying one.
- Who is responsible for the data — the project, or the customer whose staff these are? A directory is usually populated with someone else's employees. Whether the project decides what the records are for or merely holds them on instruction decides who owes which obligations — settled in a contract, but it determines which requests the product must be able to serve at all.
- What does erasure mean here? The design is soft deletion. The question left open is what a request to erase a person actually does: remove the row, which breaks every record that resolves through it into a blank, or anonymise in place, which keeps history navigable and may still leave the person identifiable through what those records say. Both are defensible; neither is free. Left unanswered, the answer is "nothing", indefinitely.
- How far does erasure reach? Assignments, comments, approvals and the append-only trail all carry a person's name or resolve to it. Whether the trail is in scope is a legal judgement, and the two answers build different systems — a trail that must be erasable is not an append-only trail.
- Is group membership itself personal data? It describes a person: their role, their relationship to an organisation, and by implication what they may do. If it is, keeping removed memberships readable becomes a retention decision needing a stated period rather than a free one taken for the sake of answerable history.
- How long is a retired person's record kept, and what starts the clock? A directory has no natural expiry. Absent a stated rule the answer is forever, which is a decision made by omission.
- Can the project answer "what do you hold about me?" The directory itself can, easily; the references to it, spread across every capability that names a person, are the hard part.
- Where a directory write provisions an account in an external identity system, personal data crosses an organisational and possibly a jurisdictional boundary on every creation, which makes the covering agreement unavoidable rather than optional.
Where a project cannot answer one of these, record it as an open question. A blank reads as "not applicable", and for a directory that is almost never true.
Cybersecurity Considerations
What is stored is dull and valuable. Names, contact details and memberships; no credential material. That absence makes the store look low-sensitivity, and it is not: a complete list of an organisation's people, with addresses and the groups they sit in, is a phishing and social-engineering asset in its own right, obtainable without the ability to change anything. Read access to a directory is a privilege, not a convenience.
Membership writes are access grants. Adding someone to a group hands them everything the group holds. The consequence usually missed is governance: the permission that edits membership belongs in the same review as the permissions that assign access directly, not with directory housekeeping. A project that separates "administers access" from "administers the directory" has, without intending to, given the second the first.
A directory write can reach outside the directory. Where creating a person also provisions a sign-in account, the blast radius of the create permission includes the external identity system.
Isolation is the likeliest failure, and it is invisible in the response. Where a person is held once and scope is derived per query, the realistic threat is not a crafted request but an ordinary read that returns the whole directory where it should have returned one scope's. Both responses are well-formed lists of people; nothing in the shape distinguishes them. That is why this is caught by tests that seed two scopes and by essentially nothing else.
Pickers disclose contact details by design, to separate two people with the same display name, which exposes every colleague's address to everyone holding the picker's read permission. Whether a less disclosing disambiguator would do — a partial address, a group, a job title — is a decision, and the default answer is the most disclosing one.
Uniqueness makes creation an oracle: a create or lookup that reports a conflict has confirmed that an address is registered. And address reuse after retirement needs care at the read — the concept deliberately frees a retired person's address, so any lookup by address that is not explicit about which record it means can attach new activity to the wrong person, an integrity failure that presents as a data-entry mistake and is diagnosed as one.
Risk Assessment
Business risks
- There is no degraded mode, and everything that names a person depends on this. The directory's availability target is set by its most demanding consumer, never by itself, and an outage stops assignment, approval, mentions and notification at the same moment.
- A scoping defect is a personal-data incident, not a bug. One customer's staff list reaching another is reportable in most regimes. Severity is set by the content, which is how the least interesting store in the system comes to carry one of its highest-consequence defects.
- An erasure request that cannot be satisfied is a standing exposure. Invisible until someone asks, urgent from that moment. See Legal Context.
- Attribution rests entirely on the trail. Where success paths deliberately log nothing — the choice Logging warns about — the trail is the only record that a directory change happened at all. If it is specified but not yet written, or written with a constant actor, who granted whom access is unanswerable; and nobody notices, because the change itself succeeded.
Technical risks
- Derived scope is a per-query guarantee, permanently. Not a defect to be fixed once but a property to be maintained for the life of the system, on every read anyone adds.
- The unpaginated read degrades linearly, silently, and has no defined threshold, so the point at which it stops being acceptable is discovered rather than anticipated.
- Substring filters and sorting are the reads most often left unindexed. They are correct and instant on a directory of fifty people, which is exactly the size at which they get written.
- The dependency on access control runs both ways wherever scope membership is derived — Scope membership lists the four consequences. The operational one is that a fault in either capability presents as a fault in the other, so incidents are misdiagnosed before they are fixed.
- Cache invalidation ends up asymmetric. Granting paths get invalidation because whoever tests a grant notices when it is missing; revoking paths — deactivating or deleting a group — are easy to omit, and the omission is invisible in testing because a cold cache hides it.
- Deduplicating membership in application code is not deduplication. Two concurrent additions each read a list without the other's row. A constraint on the pair is the only version that holds; the application-code version passes every sequential test that will ever be run against it.
- Residual risks that no design removes, and which belong in a project's analysis as accepted rather than mitigated: the two-system creation leaves a residue in one direction or the other — a project chooses which residue, not whether to have one; soft deletion leaves personal data in place by construction, so the erasure procedure lives outside this capability; and a person held once has no schema-level isolation, which review discipline and a shared predicate reduce and nothing eliminates.
Auditing, Reporting & Measurement
The questions the trail exists to answer: who added this person, and when; who put them into this group, and who took them out; what did this record say at the moment something else happened, given the values have changed since; and which groups was this person in on a given date. The last one is what justifies marking membership rows removed rather than erasing them — a hard delete makes it permanently unanswerable, and nothing else in the design notices the loss.
Who may read a person's history. A read pinned to one person or one group asks a different question from browsing the trail across every entity, and should be authorised by the permission that opens that record rather than by the permission that opens the cross-entity browser. Conflating the two either hides a person's own history from the administrator looking straight at their record, or hands out the browser to everyone who can read a name.
A structural reporting gap follows from the scoping decision. Where a record carries no scope, its entries are system-level, so no scoped administrator can read them at all. The concept declines to file against a derived scope for good reasons; this is the price, and the per-person view is only a partial substitute. The cause is the data model, not the audit design.
Reporting that usually does not exist, and is worth naming rather than assuming. Headcount over time, group composition, dormant people, who holds access through which group, administrative activity per administrator. The trail is browsed, not aggregated. A directory ordinarily ships with none of these, and the absence is rarely recorded as a decision — which is the difference between a gap and an oversight. Two measurements follow directly from risks above and are normally absent: the failure rate of external account provisioning, since every failure withdraws a record an administrator believed they had created, and the size the unpaginated reads return over time.
The gap most likely to matter and least likely to be built: nothing records reads. For a store whose principal asset is readable personal data, who looked at the staff list is the question an investigation actually asks, and a change-only trail cannot answer it. Logging directory reads has a real cost — volume, and a second store of personal data describing who read what — which is why it is usually not done. It should at least be declined explicitly.
Is a person scoped, or held once?
The first decision, and it propagates into every query in the system.
| Held once, system-wide | One record per scope | |
|---|---|---|
| Someone working in several scopes | one record, one login, one history | several unrelated records |
| Isolation between scopes | derived by each query | enforced by the data model |
| A scoped listing | needs a rule for who is visible where | a column predicate |
| Renaming a person | once | once per scope, and they will drift |
| Accountability | one subject to hold responsible | one per scope, related by nothing |
Held once is right when people genuinely work across scopes — shared-service teams, external accountants, your own staff. Holding them per scope would give one human several identities and a history that cannot be followed, and would make "who is this?" unanswerable across a boundary.
Per scope is right when scopes are genuinely separate customers whose staff never overlap. It also buys the thing the other option cannot: isolation the model enforces rather than each query re-deriving.
The cost of holding a person once is worth naming plainly, because it is easy to accept without noticing. If the record carries no scope, then isolation is a property of each individual query rather than of the schema. A query that forgets its scope predicate looks exactly like one that could not have expressed it, and no schema review will find the difference. Two mitigations, and only the second is structural: review discipline, or a single shared predicate that every scoped read is required to go through.
A project must state which it chose, and if it chose "held once", must also state where the scope predicate lives.
Scope membership: a stored fact, or derived from access?
If people are held once, something still has to answer "who belongs to this scope?" — for pickers, listings and mention suggestions. There are two answers, and projects have been known to build both and use the wrong one.
Stored. A membership relation between person and scope. Belonging is a fact in its own right, set when someone is onboarded and cleared when they leave.
Derived. Someone belongs to a scope if they hold an access grant there — directly, or through a group that holds one. Belonging is a consequence of being able to do something.
Derivation is seductive because it removes a table and can never disagree with the grants. It has four consequences, and none of them is obvious in advance:
- Belonging and access become the same fact. Someone with no grants does not appear in the directory of the scope they were just onboarded into, so they cannot be assigned anything — and the fix is to grant them something, which is the wrong tool.
- The dependency runs both ways. Access control names directory records as its subjects, and the directory reads access grants to decide who is where. Neither can be reasoned about alone, and a fault in either presents as a fault in the other.
- The derivation rule multiplies. "Holds a grant here" and "holds a grant here through an active set carrying an allow rule" are different predicates, and both will get written, in different places, by different people. They disagree on exactly the interesting cases — a removed member, an inactive set, a deny-only grant, and a scope-less grant: whether a global assignment confers membership of every scope, and whether the answer is the same for a person's own global grant and for one held through a group. A project that has never asked that last question has already answered it differently in each rule. A project with several such rules has several answers to who belongs, and the one a caller gets depends on which endpoint they asked.
- It cannot be written down. History, notification targeting and reporting all want to record where someone was at a moment in time. A derived answer changes retroactively when a grant changes, so any record built on it is a record of today's inference, not of what was true.
The concept's position: store it. Derive nothing that is asked as often as this. A stored membership costs one table and makes the scope predicate a column comparison instead of a correlated subquery over the access tables — which is also the difference between an indexed lookup and a cost that grows with the number of grants in the system.
Two traps to name explicitly. A project can have a membership table and not use it: the table gets built during onboarding work, the scoped reads get written against the access grants because that is what was available at the time, and nothing ever reconciles them. Both paths then exist, one is authoritative by accident, and the analysis draws the table that isn't used. And a project can have a membership model with no table behind it: the class survives the migration that dropped the table, an endpoint keeps the name, and the analysis draws it from the code. That is the worse of the two, because a reader of the code has no way to tell a model from a table until they look for the schema — and the schema is the one place nobody looks when the code is right in front of them. A project that derives membership deletes the relation from its model, not only from its database.
Deactivation is not deletion
Both take someone out of circulation, and they mean different things:
| Deactivate | Delete | |
|---|---|---|
| Meaning | not available for new work | no longer in the directory |
| Reversible | yes | no, as far as the interface is concerned |
| Relationships | kept, and still readable | kept, and still readable |
| Appears in pickers | no | no |
| Existing references | keep resolving | keep resolving |
| Typical cause | leave of absence, suspension, dormancy | someone has left |
Neither erases anything. Deletion here means marking the row deleted, not removing it — because work assigned last year still points at the person who did it, and history that resolves to a blank is not history. That has consequences that must be stated rather than discovered:
- Deletion must be decided at the edges. Delete withdraws the ability to sign in, and clears the cached identity lookups so a live session cannot keep resolving to them. Deactivate typically does neither, which means a deactivated person's existing session survives until it next re-resolves — usually fine, occasionally not, and a project should say which it intends. Both must also be named as triggers for the decided-capabilities cache that Permission Model owns, or explicitly not: a deactivated person who keeps their cached decisions has not been deactivated as far as authorisation is concerned, for as long as the cache lives.
- What else the deletion touches is a decision, not a detail. Leaving group memberships and access grants in place keeps them pointing at a deleted person — harmless if every read filters deleted records, and an access leak if any does not. Withdrawing them makes the deletion clean and destroys the record of what the person held.
- Soft deletion and the right to erasure are in tension. A directory holds personal data, and a regulation that grants erasure is not satisfied by a flag. Every project holding personal data this way needs a defined hard-erasure procedure, and the append-only audit trail — which also carries names — is part of what that procedure has to reach. "Delete is soft" is the design; "and here is how a person is actually erased" is the part usually missing.
Known instances
None recorded in this copy. Add this project's instance here as it adopts the concept — one line naming the feature that implements it. This is the only place a concept may name a project, and the template ships it empty so a fork inherits no other project's entries.