> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Organizations, Users, Memberships and Invitations

> After this ships, a company has an account, its administrator can invite the team, and someone who works with three of our customers signs in once.

## Context

Everything else in the platform hangs off knowing which organization a request belongs to.

The decision worth stating early is that **a user's email is unique per organization, not across
the database** (`standards/data.md` §2b). The same person may hold an account with two of our
customers, and those accounts are unrelated: two rows, two credentials, two sessions, and neither
organization can discover that the other exists.

The convenience this gives up is real and worth naming: an agency managing three clients maintains
three logins rather than one, with a switcher. We accept that. A globally unique email would make
"invite this address" an oracle revealing whether that person already works with someone else, and
would create one credential whose compromise reaches several of our customers at once. Isolation
wins over convenience here; if the complaint becomes loud, optional account linking can be added
later without a migration, because it would be a new table rather than a changed constraint.

The second is that **an invitation is evidence**. Who let whom into an account, and when, is one of
the first questions asked after an incident. Accepted invitations are retained, not consumed and
discarded.

## Scope *(normative)*

* `identity.organizations`, one per tenant, with slug and locale settings.
* `identity.users`, email unique **per organization** (`UNIQUE (organization_id, email)`).
* `identity.memberships` carrying role assignments.
* `identity.invitations` with single-use expiring tokens.
* Deactivation of a membership without deleting history.
* Organization creation during signup, and the first user becoming its owner.

## Non-scope *(normative)*

* Authentication itself — FS-IDN-0003. This delivers who exists, not how they prove it.
* Roles and permissions — FS-IDN-0002. A membership carries assignments; what they mean lives there.
* Billing and plan configuration. Entitlements are read by this module, never owned by it.
* SCIM and directory sync — F2. The membership model is shaped so it can be added without migration.

## Behaviour *(normative)*

1. A user's email is unique **per organization**. The same address in two organizations is two
   independent users with independent credentials, and neither organization can learn of the other.
   FORBIDDEN: any global index, lookup or linking flow on a user's email.
2. An organization always has **at least one active membership holding the owner role**. The last
   owner cannot be removed or demoted — the operation is rejected, not warned about.
3. Signup creates the organization and the first membership in the **same transaction**. An
   organization without a user is an invalid state.
4. An invitation is a single-use token with an expiry. Accepting it creates the membership and
   marks the invitation accepted; the row is **retained**, never deleted.
5. An invitation to an email that already has a user **in this organization** attaches to that
   user. An address that exists in another organization is treated as new here — the invitation
   flow must never reveal, by response, timing or wording, that the address is known elsewhere.
6. Deactivating a membership revokes that user's sessions **for that organization only**. Their
   access to other organizations is untouched.
7. A membership is **never hard-deleted**. Deactivation keeps the audit trail intact, and a
   returning employee gets their membership reactivated rather than recreated.
8. Every mutation writes to `core.audit_log` with a typed actor (ADR-017).
9. FORBIDDEN: a membership without an organization · a user with no membership anywhere persisting
   indefinitely · an invitation reusable after acceptance or expiry.

## Data *(normative)*

| Table                    | Key invariants                                                                                                                                      |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity.organizations` | `slug` unique, immutable after creation; `name`; `default_locale`, `timezone`; `is_active`; never hard-deleted                                      |
| `identity.users`         | `organization_id` NOT NULL; `email` citext, **unique per (`organization_id`, `email`)**; `name`; `locale`; `is_active`; `last_seen_at`              |
| `identity.memberships`   | unique (`user_id`, `organization_id`); `is_active`; `deactivated_at`; at least one active owner per organization, enforced by a deferred constraint |
| `identity.invitations`   | `token_hash` unique; `email`, `organization_id`, `roles`, `invited_by`; `expires_at`, `accepted_at`; retained after acceptance                      |

No table here is partitioned. Row counts are bounded by customer headcount, not by their customers'.

## API *(normative)*

| Endpoint                                        | Class      | Permission                              | Budget    |
| ----------------------------------------------- | ---------- | --------------------------------------- | --------- |
| `POST /v1/identity/organizations`               | Management | signup flow, public                     | p95 \<1 s |
| `GET/PATCH /v1/identity/organizations/current`  | Management | `identity.organizations.{read\|update}` | p95 \<1 s |
| `GET /v1/identity/memberships`                  | Management | `identity.memberships.read`             | p95 \<1 s |
| `POST /v1/identity/invitations`                 | Management | `identity.invitations.create`           | p95 \<1 s |
| `POST /v1/identity/invitations/{token}/accept`  | Management | public, token-authenticated             | p95 \<1 s |
| `POST /v1/identity/memberships/{id}/deactivate` | Management | `identity.memberships.deactivate`       | p95 \<1 s |

## Events *(normative)*

`identity.user.invited`, `identity.user.joined` and `identity.user.deactivated`. Not offered as
outgoing webhooks in F1: a tenant's own staff changes are not something their integrations should
subscribe to by default, and opening it invites a data-exposure question nobody asked for.

## Acceptance criteria *(normative)*

1. Signup creates organization and owner membership in one transaction; an induced failure leaves
   neither.
2. The same email invited to a second organization creates an independent user there. The response,
   its timing and its wording are identical to inviting an address nobody has ever used.
3. Removing or demoting the last owner is rejected with a typed error.
4. An accepted invitation cannot be accepted again; an expired one is rejected with a distinct
   typed reason.
5. Deactivating a membership revokes sessions for that organization and leaves sessions for other
   organizations valid.
6. An accepted invitation row is still readable a year later, naming who invited whom.
   6b. No endpoint, error message or timing difference reveals that an email exists in another
   organization.
7. RLS denial test: a query under organization A returns no rows from organization B.
8. **Negative:** no endpoint hard-deletes a membership or an organization.

## Execution

Single slice, synchronous command. Schema in the identity foundational migration; endpoints in
`backend/api`. Invitations are delivered through `messaging`, consuming the `user.invited` event —
this module never talks to an email provider directly.

## Open questions

| # | Question                                                                                                                   | Decides | By                                                              |
| - | -------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------- |
| 1 | Invitation expiry — 7 days, or 14?                                                                                         | Daniel  | before approval                                                 |
| 2 | Can a user delete their own account, and what happens to their memberships?                                                | Daniel  | before approval                                                 |
| 3 | Do we offer optional account linking later, so an agency can switch between its client accounts without re-authenticating? | Daniel  | not now — recorded so the answer is deliberate when it is asked |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
