# Tenancy Module (`sandloom_tenancy`)

Tenancy is an **optional** module managing multi-tenant organizational structure: organizations (tenant roots), membership with roles (owner / admin / member), and invitations (create / accept / revoke). It exposes a cross-module **membership read contract** so other modules (e.g. billing) can resolve membership and roles without importing tenancy internals, and a critical claims contributor that puts the user's organization ids (`tenant_ids`) into access tokens.

## Domain entities

| Model | Table | Description |
| --- | --- | --- |
| `Organization` | `tenancy_organizations` | Organization / tenant root. |
| `OrganizationMember` | `tenancy_members` | User ↔ org membership with role. |
| `OrganizationInvitation` | `tenancy_invitations` | Pending / accepted / revoked invite. Only `sha256(token)` is stored (`token_hash`); a partial unique index allows one open invitation per organization + email (case-insensitive). |

## Service contract: `tenancy.membership.v1`

The sanctioned cross-module access path — consumers call `core.get_service("tenancy.membership.v1")` rather than importing tenancy. The registered object is the **read-only** `MembershipReadContract`; the write side (add / change role / remove) is not reachable by other modules or RPC callers.

```
membership = core.get_service("tenancy.membership.v1")
role = await membership.get_role(org_id, user_id)   # no session argument
```

| Method | Purpose |
| --- | --- |
| `get_role(organization_id, user_id)` | the user's role in an active org, or `None` |
| `is_member(organization_id, user_id)` | membership check |
| `has_role_at_least(organization_id, user_id, minimum)` | live role-hierarchy check |
| `list_user_organization_ids(user_id)` | active orgs a user belongs to |
| `get_member_state(organization_id, user_id)` | `MemberState(role, version)` read in one statement — what a member-event consumer stores when it refreshes on a version-less event (rule in `sandloom.shared.membership`) |

Methods take **no session**: each call runs in tenancy's own read-only Unit of Work on tenancy's database, identically in-process and over internal RPC (`SERVICE_BINDINGS`). A caller still passing a session gets a `DeprecationWarning` and the session is ignored.

`tenancy.claims.v1` (auto-registered, `critical=True`) contributes `tenant_ids`; if tenancy cannot answer, token issuance fails with `503` instead of minting a token without the allowlist.

## HTTP API

| Router | Prefix | Endpoints |
| --- | --- | --- |
| Organizations | `/api/v1/orgs` | `POST ""`, `GET ""`, `GET /{org_id}` (member), `PATCH /{org_id}` (admin), `DELETE /{org_id}` (owner), `POST /{org_id}/transfer-ownership/{new_owner_id}` (owner) |
| Own membership | `/api/v1/orgs/{org_id}/membership` | `GET ""`: live caller role in an active organization; non-member/inactive/missing → `404` |
| Members | `/api/v1/orgs/{org_id}/members` | `GET ""` (member), `PATCH /{member_user_id}` (admin), `DELETE /{member_user_id}` (member: self = leave) |
| Invitations (org) | `/api/v1/orgs/{org_id}/invitations` | `POST ""`, `GET ""`, `DELETE /{invitation_id}` (admin) |
| Invitations (user) | `/api/v1/invitations` | `POST /accept` |

Non-members get `404` on org-scoped routes (no existence leak); a member below the required role gets `403`.

### Rules API clients must know

- **New memberships need a fresh access token.** After creating an organization or accepting an invitation, call `POST /auth/refresh` with your current refresh token and replace **both** stored tokens with the returned pair. Existing access tokens retain their earlier `tenant_ids` claim; selecting the new organization with `X-Tenant-Id` on a tenant-scoped application route returns `403` even though the membership exists. Tenancy's own membership endpoints check membership live and do not all require refreshed claims. Logging in again also mints current claims. See the [first access-token walkthrough](<https://sandloom.dev/docs/modules/batteries/first-access-token/#refresh-after-creating-an-organization>).
- **`PATCH /api/v1/orgs/{org_id}`** accepts only `name`; any other field (including `is_active`) is rejected with `422`. Deactivation is the owner-only `DELETE`, which soft-deletes the org, publishes `tenancy.organization_deleted.v1` and revokes every member's access tokens (immediately, see below).
- **Roles** — only the owner may change or remove an admin. The owner role is never assigned through `PATCH .../members/...` (`409`); use ownership transfer. A role change revokes the member's access tokens (a no-op change revokes nobody). Roles are read live today, so this is not needed for correctness yet; it ends the stale session and keeps a future membership cache / role claims safe. **UX: the member's next request answers `401 "Token has been revoked"`; the client refreshes (or logs in) and continues under the new role.**
- **Leaving** — any member may leave with `DELETE .../members/{own user id}`; the owner cannot leave or be removed (`409`) and must transfer ownership first. Removal and leaving revoke the user's access tokens, so a stale `tenant_ids` claim stops working immediately (the client refreshes).
- **Immediate revocation** — removal, leaving, role change, ownership transfer and organization deletion revoke with `require_immediate=True`: the mark is written BEFORE the change commits, or the change is refused with `503` (cache unreachable) and rolls back. A committed change therefore always rejects the old tokens on the next request, in every service. A request whose guards passed before the change committed still completes (authorization is fresh when the guards run — the TOCTOU window is one in-flight request).
- **Ownership transfer** — the org row is locked and the actor re-verified as the current owner; the new owner must already be a member (`409` otherwise). The previous owner becomes admin. Two `tenancy.member_role_changed.v1` events are published (new owner → `owner`, previous owner → `admin`), and BOTH parties' tokens are revoked — the owner who made the transfer re-authenticates and continues as admin.
- **Invitations** — `POST .../invitations` returns the raw token once (`InvitationTokenOut.token`) and e-mails the accept link through the `notifier` capability after the request; a second open invitation for the same email is `409`. `POST /api/v1/invitations/accept` requires the access token's `email` claim to match the invited address **and** `email_verified: true` (`403` otherwise); an invalid / expired / used token or a deleted organization is `404`; already being a member is `409`. Acceptance is single-use and atomic: a failed attempt consumes nothing.

## Bounded lists and retries

Organization and member lists default to 100 rows, with `limit` capped at 500. Use the returned `X-Next-Cursor` to continue. Pending invitations accept `limit` (default 100, maximum 500) and `skip`; responses are ordered by creation time and ID.

Send an `Idempotency-Key` on organization/member/invitation mutations to replay successful retries. Keys are optional for compatibility; when supplied, cache failures refuse the operation. Invitation retries with the same key replay the original raw token rather than losing it to a duplicate-pending conflict. The creation email is awaited by an on-commit callback, after the invitation is durable. This replay cache does not make arbitrary effects exactly once.

`TENANT_CLAIMS_MAX_ORGANIZATIONS` defaults to 128. Claim issuance and the membership-ID read contract refuse an oversized list rather than silently truncating authorized tenants or minting an unbounded token. Increase the setting deliberately, taking proxy/header limits into account.

## Published events

```
tenancy.organization_created.v1    # {organization_id, actor_id?}
tenancy.organization_updated.v1
tenancy.organization_deleted.v1
tenancy.member_added.v1            # {organization_id, user_id, role?, actor_id?, version?}
tenancy.member_removed.v1
tenancy.member_role_changed.v1
tenancy.member_added.v2            # {tenant_id, organization_id, user_id, role?, actor_id?, version}
tenancy.member_removed.v2          # dual-published with v1; version required,
tenancy.member_role_changed.v2     # so a Projection consumes them directly
tenancy.invitation_created.v1      # {organization_id, invitation_id, role?, actor_id?}
tenancy.invitation_accepted.v1
tenancy.invitation_revoked.v1
```

Invitation events carry **no email**: the `email` property stays in the v1 schema as deprecated (never emitted); resolve the invitee via `invitation_id`.

## Audit actions

`tenancy.member.role_changed`, `tenancy.member.removed`, `tenancy.member.left`, `tenancy.organization.ownership_transferred`, `tenancy.organization.deleted`, `tenancy.invitation.created`, `tenancy.invitation.revoked`, `tenancy.invitation.accepted`.

## Capabilities &amp; configuration

- **Requires:** `database`, `eventbus`. Descriptor: `required=False`.
- No tenancy-specific env vars; uses the default `database` + `eventbus` instances and the `notifier` for invitation e-mails (best-effort; the link is `{FRONTEND_URL}/invitations/accept#token=<token>` — the token is a URL fragment so it stays out of server logs).

## Integration with multi-tenancy

Tenancy provides the organizational model; the framework's [tenant scoping](<https://sandloom.dev/docs/modules/database/multi-tenancy/index.md>) (`TenantScoped` / `TenantScope`) provides the fail-closed data isolation. They are complementary — tenancy is the *who*, tenant scoping is the *enforcement*. The JWT `tenant_ids` allowlist may lag membership changes only if the revocation store is unreachable; checks that must observe a removal with certainty use the live contract (`has_role_at_least` / `is_member`, or the shared `OrgRole` atom).

## Platform owner directory

The optional owner console uses dedicated platform-admin APIs (live session check), separate from organization self-service: `GET /api/v1/platform/tenancy/organizations` (global, including inactive organizations; bounded offset paging and `q`, `is_active`, `owner_id` filters), `GET /organizations/{record_id}`, and `GET /members` (bounded paging, `organization_id` / `user_id` filters). Identity must be a successful claims source and the subject must hold the global `admin` role. Organization ownership does not grant these APIs, and using them never creates membership. See [operator guide](<https://sandloom.dev/docs/admin-console/operations/index.md>).
