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.membershipscarrying role assignments.identity.invitationswith 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)
- 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.
- 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.
- Signup creates the organization and the first membership in the same transaction. An organization without a user is an invalid state.
- 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.
- 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.
- Deactivating a membership revokes that user’s sessions for that organization only. Their access to other organizations is untouched.
- A membership is never hard-deleted. Deactivation keeps the audit trail intact, and a returning employee gets their membership reactivated rather than recreated.
- Every mutation writes to
core.audit_logwith a typed actor (ADR-017). - FORBIDDEN: a membership without an organization · a user with no membership anywhere persisting indefinitely · an invitation reusable after acceptance or expiry.
Data (normative)
No table here is partitioned. Row counts are bounded by customer headcount, not by their customers’.
API (normative)
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)
- Signup creates organization and owner membership in one transaction; an induced failure leaves neither.
- 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.
- Removing or demoting the last owner is rejected with a typed error.
- An accepted invitation cannot be accepted again; an expired one is rejected with a distinct typed reason.
- Deactivating a membership revokes sessions for that organization and leaves sessions for other organizations valid.
- 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.
- RLS denial test: a query under organization A returns no rows from organization B.
- Negative: no endpoint hard-deletes a membership or an organization.
Execution
Single slice, synchronous command. Schema in the identity foundational migration; endpoints inbackend/api. Invitations are delivered through messaging, consuming the user.invited event —
this module never talks to an email provider directly.